Skip to content

Commit 716de4e

Browse files
agjsclaude
andauthored
feat(observability): correlate Loki logs + GlitchTip events by user.id (#47)
Closes the last missing piece of UI ↔ API ↔ GlitchTip ↔ Loki correlation: every error event and every log line now carries the user's identity, and Grafana log lines have one-click data links into the matching GlitchTip search. API (apps/api): - auth.plugin.ts: once the auth middleware resolves the user from the cookie, call `Sentry.setUser({ id, email })` on the request-scoped Sentry scope. Any error captured for the remainder of the request carries `user.id` + `user.email` tags. No-op when SENTRY_DSN is unset. - logger.ts: the Pino mixin already injects trace_id + span_id from the active Sentry span on every log record; it now also reads userId from `Sentry.getCurrentScope().getUser()?.id` and emits it alongside. Unauthenticated requests get a trace id but no userId, and everything short-circuits to `{}` when Sentry isn't initialised. UI (apps/ui): - New SentryUserSync provider component: watches the useMe query and calls `Sentry.setUser({ id, email })` whenever the current user changes, `Sentry.setUser(null)` when the session goes away. Covers every entry path uniformly (fresh login, MFA verify, account switch, page-reload with an existing session) without having to remember to call setUser in each mutation. Mounted as a sibling of AbilityProvider. Promtail (infra/compose): - userId is now parsed out of the Pino JSON expressions block and promoted to Loki structured metadata alongside requestId / trace_id / span_id. Lets LogQL queries filter by user without a `| json` reparse. Grafana — API logs dashboard: - Two textbox variables (`$glitchtip_url`, `$glitchtip_org`) with dev defaults of `http://glitchtip.localhost` and `local`. One edit per environment if you point your stack at a different GlitchTip host. - Data links on three structured-metadata fields in the Application logs panel: • trace_id → opens `${glitchtip_url}/${glitchtip_org}/issues/ ?query=trace_id:<value>` in a new tab • userId → opens `…?query=user.id:<value>` in a new tab • requestId → opens Grafana Explore pre-filtered to that request's lines via a Loki `|=` query - Result: expand any log line → one click to GlitchTip search by trace, by user, or back to Loki by request id. Browser → API → DB is now a single round-trip in the dashboards. The matching Sentry side (GlitchTip event detail) shows the same trace_id + user.id as tags; a `tags.trace_id` external-link template configured once in GlitchTip's project settings (UI; not code) closes the loop back to Grafana Loki for the inverse pivot. Verification: API bun run validate green (997 pass / 2 skip / 0 fail); UI bun run validate green (495 pass, size-check + bundle-check clean); all 5 dashboards pass `python -m json.tool`; promtail-config.yml validates against `grafana/promtail:3.2.1 -check-syntax`. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fd38db2 commit 716de4e

6 files changed

Lines changed: 153 additions & 10 deletions

File tree

apps/api/src/api/auth/auth.plugin.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as Sentry from "@sentry/bun";
12
import { eq } from "drizzle-orm";
23
import { Elysia } from "elysia";
34

@@ -118,6 +119,16 @@ export const createAuthMiddleware = () =>
118119
throw ApiErrors.unauthorized("User not found");
119120
}
120121

122+
/*
123+
* Tag the active Sentry scope with the user so any error
124+
* captured for the rest of this request carries `user.id` +
125+
* `user.email`. The Pino mixin also reads this scope to inject
126+
* `userId` on every log record, so a Grafana log line and a
127+
* GlitchTip error event can be correlated by the same id.
128+
* No-op when SENTRY_DSN is unset.
129+
*/
130+
Sentry.setUser({ id: user.id, email: user.email });
131+
121132
return { user, accountId: parsed.accountId };
122133
} catch (err: unknown) {
123134
return translateJwtError(err);

apps/api/src/config/logger/logger.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,37 @@ import type { LOG_EVENTS } from "./logger.events";
66
type ILogEventName = (typeof LOG_EVENTS)[number];
77

88
/*
9-
* Inject the current Sentry/OTel span's trace_id + span_id on every log
10-
* record. Promtail extracts these as Loki structured metadata so a log
11-
* line surfaced in Grafana can be pivoted to its trace in Sentry/GlitchTip
12-
* by the same id. Returns {} when no span is active.
9+
* Inject Sentry-scoped correlation fields on every log record:
10+
* - trace_id + span_id from the active Sentry/OTel span
11+
* - userId from the current scope (set by auth.plugin.ts after the
12+
* user is resolved on an authenticated request)
13+
*
14+
* Promtail extracts these as Loki structured metadata so a log line
15+
* surfaced in Grafana can be pivoted to its trace or its user in
16+
* Sentry/GlitchTip by the same id. Each field is a no-op when its
17+
* source isn't set — unauthenticated requests get trace ids but no
18+
* userId; everything is `{}` before Sentry.init when no DSN is
19+
* configured.
1320
*/
1421
const traceMixin = (): Record<string, string> => {
22+
const fields: Record<string, string> = {};
23+
1524
const span = Sentry.getActiveSpan();
1625

17-
if (span === undefined) {
18-
return {};
26+
if (span !== undefined) {
27+
const ctx = span.spanContext();
28+
29+
fields.trace_id = ctx.traceId;
30+
fields.span_id = ctx.spanId;
1931
}
2032

21-
const ctx = span.spanContext();
33+
const userId = Sentry.getCurrentScope().getUser()?.id;
34+
35+
if (userId !== undefined) {
36+
fields.userId = String(userId);
37+
}
2238

23-
return { trace_id: ctx.traceId, span_id: ctx.spanId };
39+
return fields;
2440
};
2541

2642
/*

apps/ui/src/app/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AbilityProvider } from "./providers/AbilityProvider";
88
import { ErrorBoundaryProvider } from "./providers/ErrorBoundaryProvider";
99
import { I18nProvider } from "./providers/I18nProvider";
1010
import { QueryProvider } from "./providers/QueryProvider";
11+
import { SentryUserSync } from "./providers/SentryUserSync";
1112
import { ToastProvider } from "./providers/ToastProvider";
1213
import { AppRoutes } from "./router/routes";
1314

@@ -19,6 +20,7 @@ export const App: FC = () => {
1920
<QueryProvider>
2021
<AbilityProvider>
2122
<ToastProvider>
23+
<SentryUserSync />
2224
<AppRoutes />
2325
<CookieConsentBanner />
2426
</ToastProvider>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useEffect } from "react";
2+
3+
import * as Sentry from "@sentry/react";
4+
5+
import { useMe } from "@/features/auth/Auth.queries";
6+
7+
/*
8+
* Syncs the current /me identity into the Sentry SDK's user context so
9+
* any error captured by Sentry/GlitchTip is tagged with the actual user
10+
* (id + email). The matching `Sentry.setUser()` on the API side
11+
* (auth.plugin.ts) gives the same correlation on the server, so a
12+
* browser-side exception and the upstream API error appear under the
13+
* same user.id in GlitchTip.
14+
*
15+
* Mounted as a sibling of AbilityProvider in App.tsx. No UI; purely a
16+
* side-effect on every change to the current-user query result.
17+
*
18+
* No-op when VITE_SENTRY_DSN is empty — Sentry.init didn't run, the
19+
* setUser call is a noop. Logout sets the user to null so subsequent
20+
* unauthenticated errors aren't attributed to the last signed-in user.
21+
*/
22+
export const SentryUserSync = (): null => {
23+
const me = useMe();
24+
25+
useEffect(() => {
26+
if (me.data?.user) {
27+
Sentry.setUser({
28+
id: me.data.user.id,
29+
email: me.data.user.email
30+
});
31+
32+
return;
33+
}
34+
35+
Sentry.setUser(null);
36+
}, [me.data?.user]);
37+
38+
return null;
39+
};

infra/compose/compose/grafana/dashboards/boringstack-api-logs.json

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,57 @@
279279
},
280280
{
281281
"datasource": { "type": "loki", "uid": "loki" },
282-
"description": "Application events: request lifecycle, errors, business events. Drizzle's per-query SQL logs are filtered out — see the next panel for those. Click a line to expand structured metadata (trace_id, requestId).",
282+
"description": "Application events: request lifecycle, errors, business events. Drizzle's per-query SQL logs are filtered out — see the next panel for those. Click a line to expand structured metadata: `trace_id` and `userId` are clickable and open the matching GlitchTip search in a new tab.",
283+
"fieldConfig": {
284+
"defaults": {},
285+
"overrides": [
286+
{
287+
"matcher": { "id": "byName", "options": "trace_id" },
288+
"properties": [
289+
{
290+
"id": "links",
291+
"value": [
292+
{
293+
"title": "Search this trace in GlitchTip",
294+
"url": "${glitchtip_url}/${glitchtip_org}/issues/?query=trace_id%3A${__value.raw}",
295+
"targetBlank": true
296+
}
297+
]
298+
}
299+
]
300+
},
301+
{
302+
"matcher": { "id": "byName", "options": "userId" },
303+
"properties": [
304+
{
305+
"id": "links",
306+
"value": [
307+
{
308+
"title": "Search this user in GlitchTip",
309+
"url": "${glitchtip_url}/${glitchtip_org}/issues/?query=user.id%3A${__value.raw}",
310+
"targetBlank": true
311+
}
312+
]
313+
}
314+
]
315+
},
316+
{
317+
"matcher": { "id": "byName", "options": "requestId" },
318+
"properties": [
319+
{
320+
"id": "links",
321+
"value": [
322+
{
323+
"title": "Filter Loki to this request",
324+
"url": "/explore?left=%7B%22datasource%22%3A%22loki%22%2C%22queries%22%3A%5B%7B%22refId%22%3A%22A%22%2C%22expr%22%3A%22%7Bcompose_service%3D~%5C%22api-dev%7Capi%5C%22%7D%20%7C%3D%20%5C%22${__value.raw}%5C%22%22%7D%5D%7D",
325+
"targetBlank": true
326+
}
327+
]
328+
}
329+
]
330+
}
331+
]
332+
},
283333
"gridPos": { "h": 14, "w": 24, "x": 0, "y": 20 },
284334
"id": 8,
285335
"options": {
@@ -331,7 +381,30 @@
331381
"refresh": "10s",
332382
"schemaVersion": 39,
333383
"tags": ["boringstack", "api", "logs"],
334-
"templating": { "list": [] },
384+
"templating": {
385+
"list": [
386+
{
387+
"current": { "text": "http://glitchtip.localhost", "value": "http://glitchtip.localhost" },
388+
"hide": 0,
389+
"label": "GlitchTip URL",
390+
"name": "glitchtip_url",
391+
"options": [{ "selected": true, "text": "http://glitchtip.localhost", "value": "http://glitchtip.localhost" }],
392+
"query": "http://glitchtip.localhost",
393+
"skipUrlSync": false,
394+
"type": "textbox"
395+
},
396+
{
397+
"current": { "text": "local", "value": "local" },
398+
"hide": 0,
399+
"label": "GlitchTip org slug",
400+
"name": "glitchtip_org",
401+
"options": [{ "selected": true, "text": "local", "value": "local" }],
402+
"query": "local",
403+
"skipUrlSync": false,
404+
"type": "textbox"
405+
}
406+
]
407+
},
335408
"time": { "from": "now-30m", "to": "now" },
336409
"timepicker": {},
337410
"timezone": "",

infra/compose/compose/promtail/promtail-config.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ scrape_configs:
7979
requestId: requestId
8080
trace_id: trace_id
8181
span_id: span_id
82+
userId: userId
8283
msg: msg
8384
# Lowercase Pino's level ("INFO" → "info"). Promote as the `level`
8485
# label so Grafana's logs panel auto-colours each line by severity.
@@ -94,6 +95,7 @@ scrape_configs:
9495
requestId:
9596
trace_id:
9697
span_id:
98+
userId:
9799
# Use Pino's `time` (epoch ms) as the log entry timestamp.
98100
- timestamp:
99101
source: time

0 commit comments

Comments
 (0)