From bdcdd77695c7c3de27769f1abc1780a4123c5dc3 Mon Sep 17 00:00:00 2001 From: Pratyay360 Date: Sat, 14 Nov 2026 22:37:07 +0530 Subject: [PATCH] fix(web): attach trace context to browser log records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser logs reached PostHog through console autocapture, which sends only { level, body, attributes } and has no access to OpenTelemetry context, while posthog-js explicitly creates no spans. Browser log records therefore could never carry a trace_id, so PostHog could not link them to a trace or its errors. Emit client logs through captureLog with the span that produced them, and export those spans to the same PostHog project at /i/v1/traces so the ids resolve. Console capture is turned off explicitly, and client errors now emit a correlated record instead of a bare captureException. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- bun.lock | 2 ++ package.json | 2 ++ src/hooks.client.ts | 34 ++++++++++++++++++++--- src/lib/client/logging.ts | 41 ++++++++++++++++++++++++++++ src/lib/client/tracing.ts | 57 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/lib/client/logging.ts create mode 100644 src/lib/client/tracing.ts diff --git a/bun.lock b/bun.lock index 6b234e2..8271267 100644 --- a/bun.lock +++ b/bun.lock @@ -16,9 +16,11 @@ "@opentelemetry/api": "^1.10.0", "@opentelemetry/api-logs": "^0.223.0", "@opentelemetry/exporter-logs-otlp-http": "^0.223.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.223.1", "@opentelemetry/resources": "^2.12.1", "@opentelemetry/sdk-logs": "^0.223.1", "@opentelemetry/sdk-node": "^0.223.1", + "@opentelemetry/sdk-trace-base": "^2.12.1", "@orpc/client": "^1.16.2", "@orpc/openapi": "^1.16.2", "@orpc/server": "^1.16.2", diff --git a/package.json b/package.json index 1cfc24c..05b8929 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,11 @@ "@opentelemetry/api": "^1.10.0", "@opentelemetry/api-logs": "^0.223.0", "@opentelemetry/exporter-logs-otlp-http": "^0.223.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.223.1", "@opentelemetry/resources": "^2.12.1", "@opentelemetry/sdk-logs": "^0.223.1", "@opentelemetry/sdk-node": "^0.223.1", + "@opentelemetry/sdk-trace-base": "^2.12.1", "@orpc/client": "^1.16.2", "@orpc/openapi": "^1.16.2", "@orpc/server": "^1.16.2", diff --git a/src/hooks.client.ts b/src/hooks.client.ts index d713138..765039d 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,6 +1,8 @@ import type { HandleClientError } from "@sveltejs/kit"; import posthog from "posthog-js"; import { dev } from "$app/environment"; +import { captureLog } from "$lib/client/logging"; +import { initBrowserTracing, startSpan } from "$lib/client/tracing"; let posthogInitialized = false; @@ -8,8 +10,15 @@ export function isPostHogInitialized() { return posthogInitialized; } +function describeError(error: unknown): string { + if (error instanceof Error) { + return `${error.name}: ${error.message}`; + } + return String(error); +} + export function init() { - const key = "phc_AS2JXQsqEwNNreH5kra5Eefd5TijExE5eiLfwdzXLhPg"; + const key = "phc_AS2JXQsqEwNNreH5kra5Eefd5TijExEe5eiLfwdzXLhPg"; const host = "https://us.i.posthog.com"; if (!key || !host) { @@ -30,13 +39,32 @@ export function init() { capture_unhandled_rejections: true, capture_console_errors: false, }, + logs: { + serviceName: "lightcms-web", + environment: import.meta.env.MODE, + captureConsoleLogs: false, + }, person_profiles: "always", }); + + initBrowserTracing({ posthogKey: key, posthogHost: host }); + posthogInitialized = true; } export const handleError: HandleClientError = ({ error }) => { - if (posthogInitialized) { - posthog.captureException(error); + if (!posthogInitialized) { + return; } + + posthog.captureException(error); + + const span = startSpan("client.error"); + + captureLog("error", describeError(error), { + span, + attributes: { "error.source": "sveltekit.handleError" }, + }); + + span?.end(); }; diff --git a/src/lib/client/logging.ts b/src/lib/client/logging.ts new file mode 100644 index 0000000..56d68ff --- /dev/null +++ b/src/lib/client/logging.ts @@ -0,0 +1,41 @@ +import type { Span } from "@opentelemetry/api"; +import posthog from "posthog-js"; + +/** + * Logs emitted through this module carry the trace and span ids of the span the + * caller is working in, which is what Correlates a log record with its trace in + * PostHog. Console autocapture cannot do this: posthog-js wraps `console.*` and + * sends `{ level, body, attributes }` only, with no trace fields and no access + * to OpenTelemetry context. + */ + +export type LogLevel = "info" | "warn" | "error"; + +export type LogAttributeValue = string | number | boolean; + +export interface LogContext { + span?: Span; + attributes?: Record; +} + +export function captureLog( + level: LogLevel, + body: string, + { span, attributes = {} }: LogContext = {}, +): void { + if (!span) { + posthog.captureLog({ body, level, attributes }); + return; + } + + const { traceId, spanId, traceFlags } = span.spanContext(); + + posthog.captureLog({ + body, + level, + attributes, + trace_id: traceId, + span_id: spanId, + trace_flags: traceFlags, + }); +} diff --git a/src/lib/client/tracing.ts b/src/lib/client/tracing.ts new file mode 100644 index 0000000..b099a6c --- /dev/null +++ b/src/lib/client/tracing.ts @@ -0,0 +1,57 @@ +import { trace, type Attributes, type Span, type Tracer } from "@opentelemetry/api"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; + +/** + * Browser tracing for PostHog. + * + * posthog-js does not create spans (see the web SDK docs, "Configuring traces"), + * so a log record emitted from the browser has no trace to point at unless this + * app produces one. Spans are exported to the same project as the logs, which + * lets PostHog resolve `trace_id` on a log record to its trace. + */ + +const SERVICE_NAME = "lightcms-web"; + +interface BrowserTracingConfig { + posthogKey: string; + posthogHost: string; +} + +let tracer: Tracer | undefined; + +export function initBrowserTracing({ posthogKey, posthogHost }: BrowserTracingConfig): void { + if (tracer) { + return; + } + + const exporter = new OTLPTraceExporter({ + url: new URL("/i/v1/traces", posthogHost).toString(), + headers: { Authorization: `Bearer ${posthogKey}` }, + }); + + const provider = new BasicTracerProvider({ + resource: resourceFromAttributes({ "service.name": SERVICE_NAME }), + spanProcessors: [new BatchSpanProcessor(exporter)], + }); + + trace.setGlobalTracerProvider(provider); + tracer = provider.getTracer(SERVICE_NAME); +} + +/** + * Starts a span, or returns undefined when tracing is not configured. Callers + * own the returned span and must end it. + */ +export function startSpan(name: string, attributes?: Attributes): Span | undefined { + if (!tracer) { + return undefined; + } + + if (attributes) { + return tracer.startSpan(name, { attributes }); + } + + return tracer.startSpan(name); +}