Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@ import { Hono } from 'hono';
import { createLanguagesCard } from '../cards/languages';
import { createRepoCard } from '../cards/repo';
import { createStatsCard } from '../cards/stats';
import { fetchRepo, fetchTopLanguages, fetchUserStats } from '../fetchers/github';
import { fetchRepo, fetchTopLanguages, fetchUserStats, setObservability } from '../fetchers/github';
import type { Env, LanguagesCardOptions, RepoCardOptions, StatsCardOptions } from '../types';
import { AnalyticsCollector } from '../utils/analytics';
import { CACHE_TTL_EXPORT, CacheManager, getCacheHeaders } from '../utils/cache';
import { createObservability } from '../utils/observability';

const api = new Hono<{ Bindings: Env }>();

// Initialize observability for GitHub API tracking
api.use('*', async (c, next) => {
const observability = createObservability(c);
setObservability(observability);
await next();
});

const parseBoolean = (value: string | undefined): boolean | undefined => {
if (value === 'true') return true;
if (value === 'false') return false;
Expand Down
110 changes: 88 additions & 22 deletions src/fetchers/github.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,48 @@
import type { Env, GitHubRepo, GitHubUser, LanguageEdge, LanguageStats, UserStats } from '../types';
import { calculateRank } from '../utils';
import type { Observability } from '../utils/observability';

const GITHUB_API = 'https://api.github.com/graphql';

interface GitHubApiMetrics {
endpoint: string;
status: number;
duration: number;
rateLimitRemaining?: number;
rateLimitReset?: number;
}

let observabilityInstance: Observability | null = null;

export function setObservability(obs: Observability): void {
observabilityInstance = obs;
}

function recordGitHubMetrics(metrics: GitHubApiMetrics): void {
if (observabilityInstance) {
observabilityInstance.recordGitHubApiCall(
metrics.endpoint,
metrics.status,
metrics.duration,
metrics.rateLimitRemaining
);
}

// Always log rate limit warnings
if (metrics.rateLimitRemaining !== undefined && metrics.rateLimitRemaining < 100) {
console.log(
JSON.stringify({
level: 'warn',
type: 'github_rate_limit',
timestamp: new Date().toISOString(),
remaining: metrics.rateLimitRemaining,
reset: metrics.rateLimitReset,
endpoint: metrics.endpoint,
})
);
}
}

const USER_STATS_QUERY = `
query userStats($login: String!) {
user(login: $login) {
Expand Down Expand Up @@ -81,33 +121,57 @@ query repoInfo($owner: String!, $name: String!) {
const fetchGitHub = async <T>(
query: string,
variables: Record<string, unknown>,
token?: string
token?: string,
endpoint = 'graphql'
): Promise<T> => {
if (!token) {
throw new Error('GitHub token is required');
}

const response = await fetch(GITHUB_API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'User-Agent': 'devcard',
},
body: JSON.stringify({ query, variables }),
});

if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const start = Date.now();
let status = 0;
let rateLimitRemaining: number | undefined;
let rateLimitReset: number | undefined;

const data = (await response.json()) as { data: T; errors?: Array<{ message: string }> };
try {
const response = await fetch(GITHUB_API, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'User-Agent': 'devcard',
},
body: JSON.stringify({ query, variables }),
});

if (data.errors) {
throw new Error(`GraphQL error: ${data.errors[0].message}`);
}
status = response.status;

// Extract rate limit headers
rateLimitRemaining =
parseInt(response.headers.get('X-RateLimit-Remaining') || '', 10) || undefined;
rateLimitReset = parseInt(response.headers.get('X-RateLimit-Reset') || '', 10) || undefined;

if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}

return data.data;
const data = (await response.json()) as { data: T; errors?: Array<{ message: string }> };

if (data.errors) {
throw new Error(`GraphQL error: ${data.errors[0].message}`);
}

return data.data;
} finally {
const duration = Date.now() - start;
recordGitHubMetrics({
endpoint,
status: status || 0,
duration,
rateLimitRemaining,
rateLimitReset,
});
}
};

export const fetchUserStats = async (
Expand All @@ -118,7 +182,8 @@ export const fetchUserStats = async (
const data = await fetchGitHub<{ user: GitHubUser }>(
USER_STATS_QUERY,
{ login: username },
env.GITHUB_TOKEN
env.GITHUB_TOKEN,
'user_stats'
);

const user = data.user;
Expand Down Expand Up @@ -169,7 +234,7 @@ export const fetchTopLanguages = async (
}>;
};
};
}>(TOP_LANGUAGES_QUERY, { login: username, first: 100 }, env.GITHUB_TOKEN);
}>(TOP_LANGUAGES_QUERY, { login: username, first: 100 }, env.GITHUB_TOKEN, 'top_languages');

if (!data.user) {
throw new Error(`User "${username}" not found`);
Expand Down Expand Up @@ -212,7 +277,8 @@ export const fetchRepo = async (owner: string, name: string, env: Env): Promise<
const data = await fetchGitHub<{ repository: GitHubRepo }>(
REPO_QUERY,
{ owner, name },
env.GITHUB_TOKEN
env.GITHUB_TOKEN,
'repo_info'
);

if (!data.repository) {
Expand Down
125 changes: 109 additions & 16 deletions src/middleware/logger.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
import type { Context, Next } from 'hono';
import type { Env } from '../types';
import { createObservability, generateSpanId, generateTraceId } from '../utils/observability';

interface LogEntry {
timestamp: string;
level: 'info' | 'warn' | 'error';
type: 'request';
method: string;
path: string;
status: number;
duration: number;
rayId?: string;
ip?: string;
country?: string;
userAgent?: string;
cacheStatus?: string;
error?: string;
trace: {
traceId: string;
spanId: string;
rayId?: string;
};
client: {
ip?: string;
country?: string;
userAgent?: string;
colo?: string;
};
cache: {
status?: string;
hit: boolean;
};
error?: {
message: string;
type?: string;
};
}

export const loggerMiddleware = async (
Expand All @@ -21,41 +37,118 @@ export const loggerMiddleware = async (
): Promise<Response | void> => {
const start = Date.now();
const method = c.req.method;
const path = new URL(c.req.url).pathname;
const url = new URL(c.req.url);
const path = url.pathname;

// Cloudflare-specific headers
const rayId = c.req.header('CF-Ray');
const ip = c.req.header('CF-Connecting-IP');
const country = c.req.header('CF-IPCountry');
const userAgent = c.req.header('User-Agent');
const colo = c.req.header('CF-IPCity') || c.req.header('CF-Ray')?.split('-')[1];

let error: string | undefined;
// W3C Trace Context
const incomingTraceParent = c.req.header('traceparent');
let traceId: string;
let spanId: string;

if (incomingTraceParent) {
const parts = incomingTraceParent.split('-');
traceId = parts[1] || generateTraceId();
spanId = generateSpanId();
} else {
traceId = rayId?.split('-')[0] || generateTraceId();
spanId = generateSpanId();
}

// Add trace context to response headers
c.header('X-Trace-Id', traceId);
c.header('X-Span-Id', spanId);
if (rayId) {
c.header('X-Ray-Id', rayId);
}

// Create observability instance
const observability = createObservability(c);

let errorMessage: string | undefined;
let errorType: string | undefined;

try {
await next();
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
errorMessage = e instanceof Error ? e.message : 'Unknown error';
errorType = e instanceof Error ? e.constructor.name : 'UnknownError';

// Record error to Analytics Engine
observability.recordError({
path,
errorType: errorType,
errorMessage: errorMessage,
status: 500,
});

throw e;
} finally {
const duration = Date.now() - start;
const status = c.res?.status ?? 500;
const cacheStatus = c.res?.headers.get('CF-Cache-Status') ?? undefined;
const cacheStatus =
c.res?.headers.get('CF-Cache-Status') ?? c.res?.headers.get('X-Cache-Status');
const cacheHit = cacheStatus === 'HIT';

// Determine log level based on status
let level: 'info' | 'warn' | 'error' = 'info';
if (status >= 500) {
level = 'error';
} else if (status >= 400) {
level = 'warn';
}

const logEntry: LogEntry = {
timestamp: new Date().toISOString(),
level,
type: 'request',
method,
path,
status,
duration,
rayId,
ip,
country,
userAgent,
cacheStatus,
error,
trace: {
traceId,
spanId,
rayId,
},
client: {
ip,
country,
userAgent,
colo,
},
cache: {
status: cacheStatus ?? undefined,
hit: cacheHit,
},
};

if (errorMessage) {
logEntry.error = {
message: errorMessage,
type: errorType,
};
}

// Output structured JSON log for Cloudflare Workers
console.log(JSON.stringify(logEntry));

// Record to Analytics Engine
observability.recordRequest({
method,
path,
status,
duration,
cacheHit,
country,
endpoint: path.startsWith('/api/') ? path.split('?')[0] : undefined,
username: url.searchParams.get('username') ?? undefined,
});
}
};
16 changes: 16 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,20 @@ export interface Env {
GITHUB_TOKEN?: string;
CACHE?: KVNamespace;
ASSETS: Fetcher;
ANALYTICS?: AnalyticsEngineDataset;
LOG_LEVEL?: string;
}

// Analytics Engine types
export interface AnalyticsEngineDataset {
writeDataPoint(event: AnalyticsEngineDataPoint): void;
}

export interface AnalyticsEngineDataPoint {
// Blobs are indexed string fields (max 20, each max 1024 bytes)
blobs?: ArrayBuffer[];
// Doubles are numeric fields for aggregation (max 20)
doubles?: number[];
// Indexes are used for filtering/grouping (max 1, max 96 bytes)
indexes?: [string];
}
Loading