Skip to content

Commit 4290278

Browse files
authored
feat: add structured logging with request correlation (#115)
1 parent 7d638cb commit 4290278

8 files changed

Lines changed: 225 additions & 45 deletions

File tree

package-lock.json

Lines changed: 138 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@privacybydesign/yivi-core": "^1.0.0",
5454
"@privacybydesign/yivi-css": "^1.0.1",
5555
"@privacybydesign/yivi-web": "^1.0.1",
56+
"pino": "^10.3.1",
5657
"sass": "^1.100.0",
5758
"svelte-i18n": "^4.0.1"
5859
},

src/app.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ declare global {
1414
yiviAttributes: Record<string, string>;
1515
} | null;
1616
locale: string;
17+
requestId: string;
18+
log: import('$lib/server/logger').Logger;
1719
}
1820
// interface PageData {}
1921
// interface PageState {}

src/hooks.server.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
1-
import type { Handle } from '@sveltejs/kit';
1+
import type { Handle, HandleServerError } from '@sveltejs/kit';
22
import { resolveSession } from '$lib/server/auth/session';
33
import { normalizeLocale } from '$lib/i18n';
4+
import { logger } from '$lib/server/logger';
5+
6+
const REQUEST_ID_MAX = 64;
7+
8+
// Use a client-supplied X-Request-Id when present (sanitised), otherwise mint
9+
// one. Keeps correlation across a proxy without trusting arbitrary input.
10+
function resolveRequestId(header: string | null): string {
11+
if (header) {
12+
const cleaned = header.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, REQUEST_ID_MAX);
13+
if (cleaned) return cleaned;
14+
}
15+
return crypto.randomUUID();
16+
}
417

518
export const handle: Handle = async ({ event, resolve }) => {
19+
const requestId = resolveRequestId(event.request.headers.get('x-request-id'));
20+
event.locals.requestId = requestId;
21+
event.locals.log = logger.child({ requestId });
22+
623
const token = event.cookies.get('pg_session');
724

825
if (token) {
@@ -19,12 +36,39 @@ export const handle: Handle = async ({ event, resolve }) => {
1936
// per-request inside `+layout.ts`.
2037
event.locals.locale = normalizeLocale(event.request.headers.get('accept-language'));
2138

39+
const start = performance.now();
2240
const response = await resolve(event);
41+
const durationMs = Math.round(performance.now() - start);
2342

43+
response.headers.set('X-Request-Id', requestId);
2444
response.headers.set('X-Frame-Options', 'DENY');
2545
response.headers.set('X-Content-Type-Options', 'nosniff');
2646
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
2747
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
2848

49+
// Skip the noisy liveness/readiness probes.
50+
const path = event.url.pathname;
51+
if (path !== '/health' && path !== '/readyz') {
52+
event.locals.log.info(
53+
{ method: event.request.method, path, status: response.status, durationMs },
54+
'request'
55+
);
56+
}
57+
2958
return response;
3059
};
60+
61+
export const handleError: HandleServerError = ({ error, event, status, message }) => {
62+
logger.error(
63+
{
64+
requestId: event.locals.requestId,
65+
method: event.request.method,
66+
path: event.url.pathname,
67+
status,
68+
err: error instanceof Error ? { message: error.message, stack: error.stack } : String(error)
69+
},
70+
'unhandled error'
71+
);
72+
73+
return { message };
74+
};

src/lib/server/logger.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import pino from 'pino';
2+
import { env } from '$env/dynamic/private';
3+
4+
// Structured JSON logger to stdout. No transport is configured, which keeps it
5+
// robust under the bundled adapter-node server; set LOG_LEVEL to control
6+
// verbosity (default: info). Request-scoped child loggers (carrying a
7+
// requestId) are created in hooks.server.ts and exposed via `event.locals.log`.
8+
export const logger = pino({
9+
level: env.LOG_LEVEL ?? 'info'
10+
});
11+
12+
export type Logger = typeof logger;

src/lib/server/services/dns-verification.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { dnsVerifications } from '$lib/server/db/schema';
33
import { eq } from 'drizzle-orm';
44
import { randomBytes } from 'crypto';
55
import { resolve } from 'dns/promises';
6+
import { logger } from '$lib/server/logger';
67

78
export async function getOrCreateDnsVerification(orgId: string, domain: string) {
89
const existing = await db
@@ -64,12 +65,15 @@ export async function verifyDns(orgId: string): Promise<{
6465
}
6566
} catch (err) {
6667
const code = (err as NodeJS.ErrnoException)?.code;
67-
console.error('[dns-verification] resolve failed', {
68-
orgId,
69-
domain: record.domain,
70-
code,
71-
message: err instanceof Error ? err.message : String(err)
72-
});
68+
logger.error(
69+
{
70+
orgId,
71+
domain: record.domain,
72+
code,
73+
err: err instanceof Error ? err.message : String(err)
74+
},
75+
'dns resolve failed'
76+
);
7377
await db
7478
.update(dnsVerifications)
7579
.set({ lastCheckedAt: new Date() })

src/routes/api/csp-report/+server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { RequestHandler } from './$types';
22

3-
export const POST: RequestHandler = async ({ request }) => {
3+
export const POST: RequestHandler = async ({ request, locals }) => {
44
try {
55
const text = await request.text();
6-
console.warn('[csp-report]', text.slice(0, 4096));
6+
locals.log.warn({ report: text.slice(0, 4096) }, 'csp violation');
77
} catch {
88
// ignore — best-effort
99
}

0 commit comments

Comments
 (0)