962ac21
commit 962ac21
6 files changed
+153 -14
Showing 6 changed files+153-14
▼astro.config.mjs
+1-0
| @@ -35,6 +35,7 @@ export default defineConfig({ | ||
35 | 35 | }, |
36 | 36 | ssr: { |
37 | 37 | noExternal: ["@radix-ui/*"], |
38 | + external: ["node:async_hooks"], | |
38 | 39 | }, |
39 | 40 | }, |
40 | 41 | }); |
41 | 42 | |
▼src/lib/logger.ts
+22-3
| @@ -1,4 +1,5 @@ | ||
1 | 1 | import pino from "pino"; |
2 | +import { getRequestContext } from "./request-context"; | |
2 | 3 | |
3 | 4 | // Custom OTLP transport for Grafana Cloud |
4 | 5 | const createOtlpTransport = () => { |
| @@ -85,14 +86,19 @@ const baseLogger = pino({ | ||
85 | 86 | // Wrap logger to also send to OTLP if configured |
86 | 87 | const createWrappedLogger = () => { |
87 | 88 | const wrap = (level: string) => (msg: string | object, ...args: unknown[]) => { |
89 | + // Bind the current request context (requestId/userId) onto the line | |
90 | + const ctx = getRequestContext(); | |
91 | + const bound = ctx | |
92 | + ? baseLogger.child({ requestId: ctx.requestId, userId: ctx.userId }) | |
93 | + : baseLogger; | |
88 | 94 | // Call original pino method |
89 | - (baseLogger as unknown as Record<string, Function>)[level](msg, ...args); | |
95 | + (bound as unknown as Record<string, Function>)[level](msg, ...args); | |
90 | 96 | |
91 | 97 | // Send to OTLP if configured |
92 | 98 | if (otlpTransport) { |
93 | 99 | const message = typeof msg === 'string' ? msg : JSON.stringify(msg); |
94 | 100 | const extra = typeof msg === 'object' ? msg : (args[0] as Record<string, unknown>) || {}; |
95 | - otlpTransport.send(level, message, extra as Record<string, unknown>); | |
101 | + otlpTransport.send(level, message, { ...(ctx || {}), ...extra } as Record<string, unknown>); | |
96 | 102 | } |
97 | 103 | }; |
98 | 104 | |
| @@ -108,4 +114,17 @@ const createWrappedLogger = () => { | ||
108 | 114 | }; |
109 | 115 | }; |
110 | 116 | |
111 | -export const logger = otlpTransport ? createWrappedLogger() : baseLogger; | |
117 | +export const logger = otlpTransport ? createWrappedLogger() : (() => { | |
118 | + // Proxy binds the request context onto every log call | |
119 | + const bind = (target: typeof baseLogger) => { | |
120 | + const ctx = getRequestContext(); | |
121 | + return ctx ? target.child({ requestId: ctx.requestId, userId: ctx.userId }) : target; | |
122 | + }; | |
123 | + return new Proxy(baseLogger, { | |
124 | + get(target, prop, receiver) { | |
125 | + const bound = bind(target); | |
126 | + const value = Reflect.get(bound, prop, receiver); | |
127 | + return typeof value === "function" ? value.bind(bound) : value; | |
128 | + }, | |
129 | + }); | |
130 | +})(); | |
112 | 131 | |
▼src/lib/request-context.ts
+56-0
| @@ -0,0 +1,56 @@ | ||
1 | +/** | |
2 | + * Per-request context (correlation IDs). | |
3 | + * | |
4 | + * Every request runs inside an AsyncLocalStorage scope carrying a | |
5 | + * requestId (+ userId when authenticated). The logger binds these onto | |
6 | + * every log line, so a single request's logs can be traced end-to-end | |
7 | + * through DB, git, and CI operations. | |
8 | + * | |
9 | + * `node:async_hooks` is initialized ONCE at server boot (middleware calls | |
10 | + * ensureRequestStorage); after that all reads are synchronous. The dynamic | |
11 | + * import keeps the Vite browser build from choking on the node builtin. | |
12 | + */ | |
13 | +import type { AsyncLocalStorage as AsyncLocalStorageType } from "node:async_hooks"; | |
14 | + | |
15 | +export interface RequestContext { | |
16 | + requestId: string; | |
17 | + userId?: string; | |
18 | + route?: string; | |
19 | +} | |
20 | + | |
21 | +let storageImpl: AsyncLocalStorageType<RequestContext> | null = null; | |
22 | +let storagePromise: Promise<void> | null = null; | |
23 | + | |
24 | +/** Initialize the ALS storage once (server boot). Safe to call repeatedly. */ | |
25 | +export function ensureRequestStorage(): Promise<void> { | |
26 | + if (storageImpl) return Promise.resolve(); | |
27 | + if (!storagePromise) { | |
28 | + storagePromise = import("node:async_hooks") | |
29 | + .then((mod) => { | |
30 | + storageImpl = new mod.AsyncLocalStorage<RequestContext>(); | |
31 | + }) | |
32 | + .catch((err) => { | |
33 | + storagePromise = null; | |
34 | + // In browser bundles this import never resolves — fail soft | |
35 | + console.error("[request-context] async_hooks init failed", err); | |
36 | + }); | |
37 | + } | |
38 | + return storagePromise; | |
39 | +} | |
40 | + | |
41 | +/** Sync accessor — valid only after ensureRequestStorage resolved. */ | |
42 | +export function getRequestContext(): RequestContext | undefined { | |
43 | + return storageImpl?.getStore(); | |
44 | +} | |
45 | + | |
46 | +export function withRequestContext<T>( | |
47 | + ctx: RequestContext, | |
48 | + fn: () => T, | |
49 | +): T { | |
50 | + if (!storageImpl) return fn(); // not initialized (browser/test) — passthrough | |
51 | + return storageImpl.run(ctx, fn); | |
52 | +} | |
53 | + | |
54 | +export function newRequestId(): string { | |
55 | + return `req_${crypto.randomUUID().slice(0, 18)}`; | |
56 | +} | |
0 | 57 | |
▼src/middleware.ts
+27-1
| @@ -1,4 +1,5 @@ | ||
1 | 1 | import { defineMiddleware } from "astro:middleware"; |
2 | +import type { MiddlewareHandler } from "astro"; | |
2 | 3 | import { eq } from "drizzle-orm"; |
3 | 4 | import { getDatabase, schema } from "./db"; |
4 | 5 | import { getUserFromRequest } from "./lib/auth"; |
| @@ -7,6 +8,11 @@ import { httpRequestDurationMicroseconds } from "./lib/metrics"; | ||
7 | 8 | import { applyCsrfProtection } from "./middleware/csrf"; |
8 | 9 | import { createRateLimitMiddleware } from "./middleware/rate-limit"; |
9 | 10 | import { isConfigured } from "./lib/config"; |
11 | +import { | |
12 | + ensureRequestStorage, | |
13 | + newRequestId, | |
14 | + withRequestContext, | |
15 | +} from "./lib/request-context"; | |
10 | 16 | |
11 | 17 | // Define tiers for different routes |
12 | 18 | const apiLimiter = createRateLimitMiddleware("api"); |
| @@ -24,6 +30,26 @@ const CSRF_EXEMPT_PREFIXES = [ | ||
24 | 30 | export const onRequest = defineMiddleware(async (context, next) => { |
25 | 31 | const { request, url } = context; |
26 | 32 | |
33 | + // ── Correlation ID: one requestId for every log line in this request ── | |
34 | + await ensureRequestStorage(); | |
35 | + const requestId = | |
36 | + request.headers.get("x-request-id") || newRequestId(); | |
37 | + return withRequestContext( | |
38 | + { | |
39 | + requestId, | |
40 | + userId: (context.locals as any)?.user?.id, | |
41 | + route: url.pathname, | |
42 | + }, | |
43 | + () => onRequestInner(context, next), | |
44 | + ); | |
45 | +}); | |
46 | + | |
47 | +async function onRequestInner( | |
48 | + context: Parameters<MiddlewareHandler>[0], | |
49 | + next: () => Promise<Response>, | |
50 | +) { | |
51 | + const { request, url } = context; | |
52 | + | |
27 | 53 | // ── Configuration Check ── |
28 | 54 | const configured = isConfigured(); |
29 | 55 | const isSetupRoute = url.pathname === "/setup" || url.pathname.startsWith("/api/setup"); |
| @@ -140,4 +166,4 @@ export const onRequest = defineMiddleware(async (context, next) => { | ||
140 | 166 | } |
141 | 167 | |
142 | 168 | return response; |
143 | -}); | |
169 | +} | |
144 | 170 | |
▼src/pages/api/repos/index.ts
+18-2
| @@ -5,7 +5,7 @@ | ||
5 | 5 | import { type APIRoute } from 'astro'; |
6 | 6 | import type { NodePgDatabase } from "drizzle-orm/node-postgres"; |
7 | 7 | import { z } from 'zod'; |
8 | -import { eq, desc, and, or, like, count } from 'drizzle-orm'; | |
8 | +import { eq, desc, and, or, like, count, inArray } from 'drizzle-orm'; | |
9 | 9 | import { getDatabase, schema } from "@/db"; |
10 | 10 | import { repositories, users } from '@/db/schema'; |
11 | 11 | import { getUserFromRequest } from '@/lib/auth'; |
| @@ -142,12 +142,28 @@ export const GET: APIRoute = withErrorHandler(async ({ request, url }) => { | ||
142 | 142 | }, |
143 | 143 | }); |
144 | 144 | |
145 | + // Org-owned repos have a null `owner` relation (ownerId = org id) — | |
146 | + // resolve org names in one batched query | |
147 | + const orgIds = repos | |
148 | + .filter((r) => r.ownerType === "organization") | |
149 | + .map((r) => r.ownerId); | |
150 | + let orgNameById = new Map<string, string>(); | |
151 | + if (orgIds.length > 0) { | |
152 | + const orgs = await db.query.organizations.findMany({ | |
153 | + where: inArray(schema.organizations.id, orgIds), | |
154 | + columns: { id: true, name: true }, | |
155 | + }); | |
156 | + orgNameById = new Map(orgs.map((o) => [o.id, o.name])); | |
157 | + } | |
158 | + | |
145 | 159 | // Transform for response |
146 | 160 | const data = repos.map((repo) => ({ |
147 | 161 | id: repo.id, |
148 | 162 | name: repo.name, |
149 | 163 | slug: repo.slug, |
150 | - fullName: `${repo.owner.username}/${repo.name}`, | |
164 | + fullName: `${ | |
165 | + repo.owner?.username ?? orgNameById.get(repo.ownerId) ?? "unknown" | |
166 | + }/${repo.name}`, | |
151 | 167 | description: repo.description, |
152 | 168 | visibility: repo.visibility, |
153 | 169 | defaultBranch: repo.defaultBranch, |
154 | 170 | |
▼src/pages/explore.astro
+29-8
| @@ -27,12 +27,27 @@ const repos = await db.query.repositories.findMany({ | ||
27 | 27 | where: eq(schema.repositories.visibility, "public"), |
28 | 28 | orderBy: [desc(schema.repositories.updatedAt)], |
29 | 29 | with: { |
30 | - owner: true, | |
30 | + owner: { | |
31 | + columns: { id: true, username: true, avatarUrl: true, displayName: true }, | |
32 | + }, | |
31 | 33 | }, |
32 | 34 | limit: perPage, |
33 | 35 | offset: offset, |
34 | 36 | }); |
35 | 37 | |
38 | +// Org-owned repos have a null `owner` relation — resolve org names in one pass | |
39 | +const orgIds = repos | |
40 | + .filter((r) => (r as any).ownerType === "organization") | |
41 | + .map((r) => r.ownerId); | |
42 | +const orgNameById = new Map<string, string>(); | |
43 | +if (orgIds.length > 0) { | |
44 | + const orgs = await db.query.organizations.findMany({ | |
45 | + where: (t, { inArray }) => inArray(t.id, orgIds), | |
46 | + columns: { id: true, name: true }, | |
47 | + }); | |
48 | + for (const o of orgs) orgNameById.set(o.id, o.name); | |
49 | +} | |
50 | + | |
36 | 51 | function timeAgo(date: string | Date) { |
37 | 52 | const seconds = Math.floor( |
38 | 53 | (new Date().getTime() - new Date(date).getTime()) / 1000, |
| @@ -201,29 +216,34 @@ const langColors: Record<string, string> = { | ||
201 | 216 | </div> |
202 | 217 | ) : ( |
203 | 218 | <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> |
204 | - {repos.map((repo) => ( | |
219 | + {repos.map((repo) => { | |
220 | + const ownerName = | |
221 | + (repo as any).owner?.username ?? | |
222 | + orgNameById.get((repo as any).ownerId) ?? | |
223 | + "unknown"; | |
224 | + return ( | |
205 | 225 | <a |
206 | - href={`/${repo.owner.username}/${repo.name}`} | |
226 | + href={`/${ownerName}/${repo.name}`} | |
207 | 227 | class="group relative" |
208 | 228 | > |
209 | 229 | <div class="absolute -inset-[1px] bg-gradient-to-r from-cyan-500/20 via-transparent to-purple-500/20 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity" /> |
210 | 230 | <div class="relative h-full rounded-xl border border-border bg-card/80 backdrop-blur-sm p-5 hover:bg-accent/50 transition-colors flex flex-col"> |
211 | 231 | <div class="flex items-start gap-3 mb-3"> |
212 | - {repo.owner.avatarUrl ? ( | |
232 | + {(repo as any).owner?.avatarUrl ? ( | |
213 | 233 | <img |
214 | - src={repo.owner.avatarUrl} | |
234 | + src={(repo as any).owner.avatarUrl} | |
215 | 235 | alt="" |
216 | 236 | class="h-8 w-8 rounded-full ring-2 ring-border" |
217 | 237 | /> |
218 | 238 | ) : ( |
219 | 239 | <div class="h-8 w-8 rounded-full bg-gradient-to-br from-cyan-400 to-blue-600 flex items-center justify-center text-sm font-bold text-foreground ring-2 ring-border"> |
220 | - {repo.owner.username[0].toUpperCase()} | |
240 | + {ownerName[0].toUpperCase()} | |
221 | 241 | </div> |
222 | 242 | )} |
223 | 243 | <div class="flex-1 min-w-0"> |
224 | 244 | <div class="flex items-center gap-2"> |
225 | 245 | <span class="font-semibold text-foreground group-hover:text-cyan-400 transition-colors truncate"> |
226 | - {repo.owner.username}/{repo.name} | |
246 | + {ownerName}/{repo.name} | |
227 | 247 | </span> |
228 | 248 | </div> |
229 | 249 | <span class="text-xs text-muted-foreground"> |
| @@ -274,7 +294,8 @@ const langColors: Record<string, string> = { | ||
274 | 294 | </div> |
275 | 295 | </div> |
276 | 296 | </a> |
277 | - ))} | |
297 | + ); | |
298 | + })} | |
278 | 299 | </div> |
279 | 300 | ) |
280 | 301 | } |
281 | 302 | |