From 0074b6887727465f098022ac7167d3dba628042e Mon Sep 17 00:00:00 2001 From: Jacob Heminway Date: Fri, 30 Oct 2026 15:41:49 -0400 Subject: [PATCH 1/2] feat(telemetry): integrate OpenTelemetry for enhanced observability - Added OpenTelemetry dependencies to package.json and package-lock.json. - Implemented a fake OTLP receiver for testing telemetry data in scripts/test-otlp.mjs. - Updated testing documentation to include telemetry testing details. - Modified Playwright configuration to disable telemetry by default. - Enhanced API tests to validate telemetry spans and ensure sensitive data is not transmitted. - Created new telemetry tests to verify trace propagation and data redaction. - Updated landing page tests to check for telemetry disclosure information. - Added observability commands to package.json for easier management of telemetry services. --- .gitignore | 2 + AGENTS.md | 2 +- app/api/.env.example | 6 + app/api/Observability/ObservabilitySetup.cs | 75 +++++++ app/api/Program.cs | 2 + app/api/SpecThread.Api.csproj | 5 + app/api/packages.lock.json | 67 ++++++ app/web/.env.example | 6 + app/web/package.json | 3 + app/web/src/app/app-shell.css | 3 + app/web/src/app/privacy/page.tsx | 23 ++- app/web/src/components/policy-page.tsx | 10 +- app/web/src/instrumentation.ts | 11 + app/web/src/lib/telemetry.ts | 95 +++++++++ app/web/src/proxy.ts | 3 + compose.observability.yaml | 11 + docs/ARCHITECTURE.md | 6 + docs/DECISIONS.md | 43 ++++ docs/DEPLOYMENT.md | 32 +++ docs/HANDOFF.md | 63 +++++- docs/OBSERVABILITY.md | 120 +++++++++++ docs/TESTING.md | 20 +- package-lock.json | 216 +++++++++++++++++++- package.json | 4 +- playwright.config.ts | 6 +- render.yaml | 5 + scripts/start-test-web.mjs | 13 +- scripts/test-otlp.mjs | 48 +++++ tests/api/telemetry.spec.ts | 81 ++++++++ tests/e2e/landing.spec.ts | 5 + tests/e2e/telemetry.spec.ts | 40 ++++ tests/support/api-process.ts | 3 +- tests/support/otlp.ts | 25 +++ 33 files changed, 1041 insertions(+), 13 deletions(-) create mode 100644 app/api/Observability/ObservabilitySetup.cs create mode 100644 app/web/src/instrumentation.ts create mode 100644 app/web/src/lib/telemetry.ts create mode 100644 compose.observability.yaml create mode 100644 docs/OBSERVABILITY.md create mode 100644 scripts/test-otlp.mjs create mode 100644 tests/api/telemetry.spec.ts create mode 100644 tests/e2e/telemetry.spec.ts create mode 100644 tests/support/otlp.ts diff --git a/.gitignore b/.gitignore index 03bc9ad..ff78b07 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,5 @@ next-env.d.ts skills/ superpowers/ + +*_plan.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a4564d2..84d674c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ Handle loading, empty, error, unauthorized, and retry states where relevant. UI work must remain keyboard accessible, responsive, and readable. -Logs must be useful without exposing tokens, credentials, or sensitive payloads. +Logs must be useful without exposing tokens, credentials, or sensitive payloads. The same applies to telemetry: follow the "Never recorded" list in docs/OBSERVABILITY.md, and fix redaction rather than loosening the telemetry guard tests. Database changes require migrations and a documented rollback consideration. diff --git a/app/api/.env.example b/app/api/.env.example index ef47c5b..2e96954 100644 --- a/app/api/.env.example +++ b/app/api/.env.example @@ -29,3 +29,9 @@ GitHub__PrivateKey="" GitHub__AppSlug="" # Tests only: a stand-in for https://api.github.com. Leave unset. # GitHub__ApiBaseUrl="" + +# Optional telemetry (ADR-055, docs/OBSERVABILITY.md). Off when the endpoint is empty. +# Locally, the viewer from `npm run observability:up` is http://127.0.0.1:4318 and needs no header. +# OTEL_EXPORTER_OTLP_ENDPOINT="" +# Deployed only: Authorization=Basic%20. Secret. +# OTEL_EXPORTER_OTLP_HEADERS="" diff --git a/app/api/Observability/ObservabilitySetup.cs b/app/api/Observability/ObservabilitySetup.cs new file mode 100644 index 0000000..69719ca --- /dev/null +++ b/app/api/Observability/ObservabilitySetup.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; +using System.Security.Claims; +using Microsoft.IdentityModel.JsonWebTokens; +using Npgsql; +using OpenTelemetry; +using OpenTelemetry.Exporter; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace SpecThread.Api.Observability; + +// Sends traces, metrics, and logs over OTLP/HTTP when OTEL_EXPORTER_OTLP_ENDPOINT is set +// (ADR-055, docs/OBSERVABILITY.md). Without it nothing is registered, so local runs, tests, +// and CI behave exactly as before. OTEL_EXPORTER_OTLP_HEADERS carries the backend's credentials. +internal static class ObservabilitySetup +{ + private const string EndpointSetting = "OTEL_EXPORTER_OTLP_ENDPOINT"; + + public static void AddSpecThreadObservability(this WebApplicationBuilder builder) + { + var endpoint = ReadEndpoint(builder.Configuration[EndpointSetting]); + if (endpoint is null) return; + + // Framework Information logs repeat what spans already record, and some include raw + // request paths. Only warnings and errors from them are sent. + foreach (var category in new[] { "Microsoft", "System", "Npgsql" }) + { + builder.Logging.AddFilter(category, LogLevel.Warning); + } + + builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => resource + // Render sets RENDER_GIT_COMMIT to the deployed commit. + .AddService("specthread-api", serviceVersion: builder.Configuration["RENDER_GIT_COMMIT"]) + .AddAttributes([new("deployment.environment.name", builder.Environment.EnvironmentName.ToLowerInvariant())])) + .WithTracing(tracing => tracing + .AddAspNetCoreInstrumentation(options => + { + // Render's health checks would outnumber real requests; metrics still count them. + options.Filter = context => context.Request.Path != "/health"; + options.EnrichWithHttpResponse = RedactAndIdentify; + }) + .AddHttpClientInstrumentation() + .AddNpgsql()) + .WithMetrics(metrics => metrics + .AddMeter("Microsoft.AspNetCore.Hosting", "Microsoft.AspNetCore.Server.Kestrel", "System.Net.Http", "System.Runtime", "Npgsql")) + .WithLogging(configureBuilder: null, configureOptions: options => options.IncludeFormattedMessage = true) + .UseOtlpExporter(OtlpExportProtocol.HttpProtobuf, endpoint); + } + + // The raw path can hold an invitation token, so it is replaced by the route template, or + // dropped when no route matched. The caller is recorded by user ID only, never email or name. + private static void RedactAndIdentify(Activity activity, HttpResponse response) + { + var context = response.HttpContext; + activity.SetTag("url.path", (context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText); + var userId = context.User.FindFirstValue(JwtRegisteredClaimNames.Sub); + if (userId is not null) activity.SetTag("user.id", userId); + } + + // Blank means off. Anything else must be an HTTP(S) URL, so a typo fails the deploy visibly + // instead of silently sending nothing. + private static Uri? ReadEndpoint(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp)) + { + return uri; + } + throw new InvalidOperationException( + $"{EndpointSetting} must be an HTTP(S) URL, or empty to turn telemetry off. See docs/OBSERVABILITY.md."); + } +} diff --git a/app/api/Program.cs b/app/api/Program.cs index 2ea9d04..b9388e5 100644 --- a/app/api/Program.cs +++ b/app/api/Program.cs @@ -5,6 +5,7 @@ using SpecThread.Api.Data; using SpecThread.Api.Documents; using SpecThread.Api.GitHub; +using SpecThread.Api.Observability; using SpecThread.Api.Projects; using SpecThread.Api.Requirements; using SpecThread.Api.Teams; @@ -13,6 +14,7 @@ // Hosting request-start/finish logs include raw invitation secrets in the URL. builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); +builder.AddSpecThreadObservability(); builder.Services.AddOpenApi(); builder.AddSpecThreadAuthentication(); diff --git a/app/api/SpecThread.Api.csproj b/app/api/SpecThread.Api.csproj index 775c0d3..0ae6421 100644 --- a/app/api/SpecThread.Api.csproj +++ b/app/api/SpecThread.Api.csproj @@ -15,5 +15,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/app/api/packages.lock.json b/app/api/packages.lock.json index 74dd6c9..79f593d 100644 --- a/app/api/packages.lock.json +++ b/app/api/packages.lock.json @@ -48,6 +48,52 @@ "Npgsql": "10.0.3" } }, + "Npgsql.OpenTelemetry": { + "type": "Direct", + "requested": "[10.0.3, )", + "resolved": "10.0.3", + "contentHash": "ShsdG/FHxJ2VuWM6tebJL/mCWkcxymmvtENN59c85EsJdT4TStWwwtgZoBu/9UvC0qi1jpFZ6bOfl9ijiWD+vA==", + "dependencies": { + "Npgsql": "10.0.3", + "OpenTelemetry.API": "1.15.3" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "Direct", + "requested": "[1.20.1, )", + "resolved": "1.20.1", + "contentHash": "IlO8F0Y01ki0N5hJaeFJ5j3MVYJdbyNpDF9g7goU8wA3o8r/+ir9WJNnmte7QlUzM3iSV/aIuev1fqwF1CZOqg==", + "dependencies": { + "OpenTelemetry": "1.20.1" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "Direct", + "requested": "[1.20.1, )", + "resolved": "1.20.1", + "contentHash": "wnoK07COxyQBR47QwP80SIO4cpD2xeF4PrBDYFXDMnhDLmOP4nN0Szy1wCBLo5UABU1I4xwWRGzZM0bFdCbRog==", + "dependencies": { + "OpenTelemetry": "1.20.1" + } + }, + "OpenTelemetry.Instrumentation.AspNetCore": { + "type": "Direct", + "requested": "[1.20.1, )", + "resolved": "1.20.1", + "contentHash": "pDO+Ed5iblMTvSVGcXzoLp8q20XHhXmK0wD/H3PGNHwDFlwZpV+bDWd/Euo6hNB4E0ruqzTFD76Btm+m0uCYNw==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.20.1, 2.0.0)" + } + }, + "OpenTelemetry.Instrumentation.Http": { + "type": "Direct", + "requested": "[1.20.1, )", + "resolved": "1.20.1", + "contentHash": "XJTM6imVztxLraOrzsdlZf5shdH9TVeEWNBxpyepB5e3Gy+KNeQf2hyic398210UxqlPtt4iOkBELtjfIFTzug==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.20.1, 2.0.0)" + } + }, "Humanizer.Core": { "type": "Transitive", "resolved": "2.14.1", @@ -230,6 +276,27 @@ "resolved": "10.0.3", "contentHash": "7nb5YzXuvWWJxB0J8DiyL3we+X4FOctZrt0fIBnucOIaIevFEEwGQVZKtiu9olXdlNAK1eNgqSral6r/jlhI4w==" }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.20.1", + "contentHash": "+XDw3fYbiv1a40aFnDJSQleeuUJciXuhoIA19XmcGhT47Hy/xZRqk8NCjHUDKrg72Ixjthicecp8ygBDNeH/Jw==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.20.1" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.20.1", + "contentHash": "QO9hlwadOdJGR4vDpJyDnl9B62v7epIBSTIHUA4C+bCtrRaB4TWmYSnu3QVOIe7caH8dt7NaHRei6zebESJJyw==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.20.1", + "contentHash": "eDzDY/jTBqRytkYECmVKF7ZP5sKmCIqv1SSnA5BSWltIIvc3931TOMRyCW1qCGNIgmtldtJhISnsmFNxOCfauQ==", + "dependencies": { + "OpenTelemetry.Api": "1.20.1" + } + }, "System.CodeDom": { "type": "Transitive", "resolved": "6.0.0", diff --git a/app/web/.env.example b/app/web/.env.example index 8256fec..2243c16 100644 --- a/app/web/.env.example +++ b/app/web/.env.example @@ -19,6 +19,12 @@ SPECTHREAD_API_URL="http://127.0.0.1:5100" # Optional dashboard integration; disabled unless an explicit key is configured. BETTER_AUTH_API_KEY="" +# Optional telemetry (ADR-055, docs/OBSERVABILITY.md). Off when the endpoint is empty. +# Locally, the viewer from `npm run observability:up` is http://127.0.0.1:4318 and needs no header. +# OTEL_EXPORTER_OTLP_ENDPOINT="" +# Deployed only: Authorization=Basic%20. Secret. +# OTEL_EXPORTER_OTLP_HEADERS="" + # GitHub OAuth credentials (GitHub sign-in is disabled when both are empty; # setting only one fails at startup) # Callback URL: {BETTER_AUTH_URL}/api/auth/callback/github diff --git a/app/web/package.json b/app/web/package.json index bc92be3..35c9306 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -10,6 +10,9 @@ }, "dependencies": { "@better-auth/infra": "0.4.9", + "@opentelemetry/api": "1.9.1", + "@opentelemetry/sdk-trace-base": "2.12.0", + "@vercel/otel": "2.2.0", "better-auth": "1.7.5", "lucide-react": "^0.563.0", "next": "16.3.5", diff --git a/app/web/src/app/app-shell.css b/app/web/src/app/app-shell.css index 2873ff2..ece8ace 100644 --- a/app/web/src/app/app-shell.css +++ b/app/web/src/app/app-shell.css @@ -210,6 +210,9 @@ a.row:focus-visible { outline-offset: -2px; } .scaffold-status-mark { flex: none; width: 11px; height: 11px; margin-top: 5px; border: 2px solid #f5f5f5; border-radius: 50%; } .scaffold-status strong { color: #eaeaea; font-size: 14px; font-weight: 600; } .scaffold-status p { margin: 6px 0 0; color: #ababab; line-height: 1.5; } +.policy-section { max-width: 720px; margin-top: 40px; } +.policy-section h2 { margin: 0; color: #efefef; font-size: 18px; font-weight: 600; } +.policy-section p { margin: 12px 0 0; color: #b6b6b6; line-height: 1.6; } .scaffold-navigation { display: block; margin-top: 44px; } .scaffold-navigation-heading h2 { margin: 0; color: #efefef; font-size: 16px; font-weight: 600; } .scaffold-navigation-heading p { margin: 6px 0 0; color: #979797; } diff --git a/app/web/src/app/privacy/page.tsx b/app/web/src/app/privacy/page.tsx index d768fdd..db26f95 100644 --- a/app/web/src/app/privacy/page.tsx +++ b/app/web/src/app/privacy/page.tsx @@ -3,7 +3,26 @@ import { PolicyPage } from "@/components/policy-page"; export const metadata: Metadata = { title: "Privacy" }; -/** Renders the public privacy page. */ +/** Renders the public privacy page. The telemetry section reflects ADR-055. */ export default function PrivacyPage() { - return ; + return ( + +
+

Operational telemetry

+

+ To keep SpecThread working, the web app and API record technical information about each + request: which page or API route was used, how long it took, whether it failed, and the + database queries it ran, without the values in them. Each record includes your account ID, + a random identifier rather than your name or email address, and for requests to the web + app, your IP address. Passwords, tokens, email addresses, and what you type or upload are + not recorded. +

+

+ This information is sent to Grafana Labs, which stores it for us for about 14 days and then + deletes it. The SpecThread team uses it to find errors and slow pages, and to investigate + misuse such as repeated failed sign-ins or automated scraping. +

+
+
+ ); } diff --git a/app/web/src/components/policy-page.tsx b/app/web/src/components/policy-page.tsx index fd0208a..612b7c8 100644 --- a/app/web/src/components/policy-page.tsx +++ b/app/web/src/components/policy-page.tsx @@ -1,5 +1,10 @@ -/** Renders a public policy page, shown with the minimal public header and never the app sidebar. */ -export function PolicyPage({ title, description }: { title: string; description: string }) { +import type { ReactNode } from "react"; + +/** + * Renders a public policy page, shown with the minimal public header and never the app sidebar. + * `children` holds the sections already published; the draft notice covers the rest. + */ +export function PolicyPage({ title, description, children }: { title: string; description: string; children?: ReactNode }) { return (
@@ -14,6 +19,7 @@ export function PolicyPage({ title, description }: { title: string; description:

This page is planned. The full text is not published yet.

+ {children}
); } diff --git a/app/web/src/instrumentation.ts b/app/web/src/instrumentation.ts new file mode 100644 index 0000000..fad3e2e --- /dev/null +++ b/app/web/src/instrumentation.ts @@ -0,0 +1,11 @@ +/** + * Next.js calls this once when the server starts. Telemetry stays off unless + * OTEL_EXPORTER_OTLP_ENDPOINT is set (ADR-055, docs/OBSERVABILITY.md): without that check + * @vercel/otel would still try to send to localhost:4318. + */ +export async function register() { + const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim(); + if (process.env.NEXT_RUNTIME !== "nodejs" || !endpoint) return; + const { startTelemetry } = await import("./lib/telemetry"); + startTelemetry(endpoint, process.env.OTEL_EXPORTER_OTLP_HEADERS); +} diff --git a/app/web/src/lib/telemetry.ts b/app/web/src/lib/telemetry.ts new file mode 100644 index 0000000..68eabd8 --- /dev/null +++ b/app/web/src/lib/telemetry.ts @@ -0,0 +1,95 @@ +import type { AttributeValue, Attributes } from "@opentelemetry/api"; +import { BatchSpanProcessor, type ReadableSpan, type SpanExporter } from "@opentelemetry/sdk-trace-base"; +import { OTLPHttpProtoTraceExporter, registerOTel } from "@vercel/otel"; +import { apiBaseUrl } from "./api-config"; + +/** + * Starts tracing for the web server (ADR-055, docs/OBSERVABILITY.md). Called from + * src/instrumentation.ts only when OTEL_EXPORTER_OTLP_ENDPOINT is set. Every span passes + * through `redactSpan` before it leaves the process, so no URL path or query is ever sent. + */ +export function startTelemetry(endpoint: string, headers: string | undefined) { + const exporter = new OTLPHttpProtoTraceExporter({ url: `${endpoint.replace(/\/+$/, "")}/v1/traces`, headers: parseHeaders(headers) }); + registerOTel({ + serviceName: "specthread-web", + attributes: { "service.version": process.env.VERCEL_GIT_COMMIT_SHA }, + // The trace continues into the SpecThread API only; other services never receive its IDs. + instrumentationConfig: { fetch: { propagateContextUrls: apiOrigin() } }, + // The visitor's IP address, which Vercel sets and visitors cannot override. @vercel/otel + // reads headers only on Vercel, so a header sent to a local server is never recorded. + attributesFromHeaders: { "client.address": "x-real-ip" }, + // Replaces the default processors, which would also export unredacted spans. + spanProcessors: [new BatchSpanProcessor(new RedactingExporter(exporter))], + }); +} + +function apiOrigin(): string[] { + try { return [`${apiBaseUrl(process.env.SPECTHREAD_API_URL).origin}/`]; } catch { return []; } +} + +/** Reads OTEL_EXPORTER_OTLP_HEADERS: comma-separated `name=value` pairs, percent-encoded. */ +export function parseHeaders(value: string | undefined): Record { + const headers: Record = {}; + for (const pair of (value ?? "").split(",")) { + const separator = pair.indexOf("="); + if (separator <= 0) continue; + headers[decodeURIComponent(pair.slice(0, separator).trim())] = decodeURIComponent(pair.slice(separator + 1).trim()); + } + return headers; +} + +/** Attributes that hold a raw path or query, which can carry an invitation or password-reset token. */ +const PATH_ATTRIBUTES = new Set(["http.target", "url.path", "url.query", "resource.name"]); +/** An absolute URL: only its scheme and host are kept, never credentials, path, or query. */ +const URL_PATTERN = /\b(https?:\/\/)(?:[^\s/?#@"'<>]*@)?([^\s/?#"'<>]+)[^\s"'<>]*/gi; + +export function redactText(text: string) { + return text.replace(URL_PATTERN, "$1$2"); +} + +function redactValue(value: AttributeValue | undefined): AttributeValue | undefined { + if (typeof value === "string") return redactText(value); + if (Array.isArray(value)) return value.map(item => typeof item === "string" ? redactText(item) : item) as AttributeValue; + return value; +} + +export function redactAttributes(attributes: Attributes): Attributes { + const result: Attributes = {}; + for (const [key, value] of Object.entries(attributes)) { + if (!PATH_ATTRIBUTES.has(key)) result[key] = redactValue(value); + } + return result; +} + +/** A copy of the span with paths, queries, and URLs removed from its name, attributes, events, links, and status. */ +export function redactSpan(span: ReadableSpan): ReadableSpan { + return { + name: redactText(span.name), + kind: span.kind, + spanContext: () => span.spanContext(), + parentSpanContext: span.parentSpanContext, + startTime: span.startTime, + endTime: span.endTime, + status: span.status.message === undefined ? span.status : { ...span.status, message: redactText(span.status.message) }, + attributes: redactAttributes(span.attributes), + links: span.links.map(link => ({ ...link, attributes: link.attributes && redactAttributes(link.attributes) })), + events: span.events.map(event => ({ ...event, attributes: event.attributes && redactAttributes(event.attributes) })), + duration: span.duration, + ended: span.ended, + resource: span.resource, + instrumentationScope: span.instrumentationScope, + droppedAttributesCount: span.droppedAttributesCount, + droppedEventsCount: span.droppedEventsCount, + droppedLinksCount: span.droppedLinksCount, + }; +} + +class RedactingExporter implements SpanExporter { + private readonly inner: SpanExporter; + constructor(inner: SpanExporter) { this.inner = inner; } + export(spans: ReadableSpan[], resultCallback: Parameters[1]) { + this.inner.export(spans.map(redactSpan), resultCallback); + } + shutdown() { return this.inner.shutdown(); } + forceFlush() { return this.inner.forceFlush?.() ?? Promise.resolve(); } +} diff --git a/app/web/src/proxy.ts b/app/web/src/proxy.ts index b6c3fb9..4c06d65 100644 --- a/app/web/src/proxy.ts +++ b/app/web/src/proxy.ts @@ -1,3 +1,4 @@ +import { trace } from "@opentelemetry/api"; import { NextResponse, type NextRequest } from "next/server"; import { auth } from "@/lib/auth"; import { isPublicPath } from "@/lib/app-navigation"; @@ -18,6 +19,8 @@ export async function proxy(request: NextRequest) { const { pathname, search } = request.nextUrl; if (isPublicPath(pathname)) return NextResponse.next(); const { headers, response: session } = await auth.api.getSession({ headers: request.headers, returnHeaders: true }); + // Who made this request, by ID only (docs/OBSERVABILITY.md). Does nothing while telemetry is off. + if (session) trace.getActiveSpan()?.setAttribute("user.id", session.user.id); let result = NextResponse.next(); if (!session) { const login = new URL("/login", request.url); diff --git a/compose.observability.yaml b/compose.observability.yaml new file mode 100644 index 0000000..0273cf4 --- /dev/null +++ b/compose.observability.yaml @@ -0,0 +1,11 @@ +# Optional local telemetry viewer (docs/OBSERVABILITY.md): Grafana with Tempo, Loki, and +# Prometheus in one container, the same stores Grafana Cloud uses. Start and stop it with +# `npm run observability:up` and `npm run observability:down`; nothing is kept between runs. +# Ports bind to loopback only. Grafana uses 3030 because `next dev` uses 3000. +name: specthread-observability +services: + lgtm: + image: grafana/otel-lgtm:0.38.1 + ports: + - "127.0.0.1:3030:3000" # Grafana + - "127.0.0.1:4318:4318" # OTLP over HTTP, where the web app and API send diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 258d537..ab92058 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,6 +50,12 @@ Docker Compose only when a task requires local dependencies No local database is required for the initial health and smoke checks +Observability + +OpenTelemetry (OTLP/HTTP) to Grafana Cloud; grafana/otel-lgtm locally + +Traces, metrics, and logs, off unless an endpoint is configured (ADR-055, OBSERVABILITY.md) + Continuous integration GitHub Actions diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c7ea7a5..f7fee62 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1397,6 +1397,49 @@ RequirementEvidenceEvents adds the table (RLS on, browser grants revoked) and st with an "added" event for every existing link, from its `linked_by` and `linked_at`; its rollback drops the table and loses the history, including every removal. +ADR-055: OpenTelemetry to Grafana Cloud, off unless configured, with redaction by design + +Status: Accepted + +Context: The team could not see how requests behave in production or under load: which +queries a page runs, where time goes between Vercel, Render, and Supabase, what fails, and +what an account or IP address has been doing when abuse is suspected. ADR-043 counted SQL +statements by hand. Vercel Hobby keeps runtime logs about an hour and cannot export them; +Render's free plan has no metrics history. Jacob proposed Grafana, Jaeger, and Sentry. The +team chose Grafana Cloud's free tier for all three signals, with Jacob owning the Grafana +stack and the Vercel/Render owner setting its variables; recording the user ID only (no team +ID), and the client IP on web traces; and naming Grafana Labs and IP addresses on the privacy +page. Jaeger would duplicate Tempo; Sentry is deferred. + +Decision: Both apps use OpenTelemetry and export OTLP over HTTP/protobuf to the endpoint in +`OTEL_EXPORTER_OTLP_ENDPOINT`, with credentials in `OTEL_EXPORTER_OTLP_HEADERS`. Unset or +blank means off: nothing is registered, so tests and CI are unaffected. The API +(`app/api/Observability/ObservabilitySetup.cs`) sends traces (ASP.NET Core, HttpClient, +Npgsql), metrics (hosting, Kestrel, HttpClient, runtime, Npgsql pool), and logs (framework +categories at Warning and above); `/health` is not traced; an endpoint that is not an HTTP(S) +URL stops startup. The web app (`app/web/src/instrumentation.ts`) sends traces through +`@vercel/otel` with its own exporter, because the library's default would also send unredacted +spans and would send to localhost:4318 even without an endpoint. Trace context goes to the API +origin only. Production only: Vercel Preview deployments do not send telemetry. Web server +logs stay in Vercel. + +Redaction is by design, not by list of routes: the API replaces `url.path` with the route +template; the web exporter drops raw path and query attributes and cuts every URL to its +scheme and host. No bodies, headers, emails, names, or SQL parameter values are recorded. The +caller is `user.id` (the Better Auth ID); the web root span carries `client.address` from +Vercel's `x-real-ip`, read only on Vercel. Neither is ever a metric label. Guard tests send +telemetry to a fake OTLP receiver and fail if a token, email, name, or session token appears. +A pinned `grafana/otel-lgtm` container is the optional local viewer. docs/OBSERVABILITY.md has +the conventions. + +Consequences: Production traces and metrics are kept 14 days on the free tier, which also +limits metric series (10,000), and active Grafana users (3 a month); durable performance +history needs another store (planned: load-test summaries). IP addresses and user IDs sent to +Grafana Labs are personal data, named on the privacy page; exports must stay team-only. A +leaked OTLP token can only write telemetry. The browser suite now runs with telemetry on +against the fake receiver, so instrumentation problems surface in tests. Rollback: delete +`OTEL_EXPORTER_OTLP_ENDPOINT` on Vercel and Render and redeploy; no code or migration change. + ADR-NNN: Title Status: Proposed, Accepted, Superseded, or Rejected diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index a1baece..ec28569 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -284,6 +284,38 @@ The project creation form must send `teamId`, and management controls use `teamR Claude owns those global project pages/forms. Team archive restoration uses the existing project restore API and rechecks current Owner/Admin permission. +## Telemetry (ADR-055) + +Optional; nothing is sent until these are set. Telemetry goes to the team's Grafana Cloud stack +(free tier); docs/OBSERVABILITY.md describes what is recorded. The Grafana owner creates the +stack and an access-policy token with write scopes for metrics, logs, and traces only, then +hands the platform owner the header value through a password manager or during a call, never +in chat, email, or issues. + +Set on **Render** (`specthread-api`, Environment) and on **Vercel** (Settings > Environment +Variables, **Production only**, so Preview deployments send nothing): + +| Variable | Value | Secret | +|---|---|---| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | The stack's OTLP endpoint (Grafana Cloud > stack > OpenTelemetry), for example `https://otlp-gateway-prod-us-west-0.grafana.net/otlp` | No | +| `OTEL_EXPORTER_OTLP_HEADERS` | `Authorization=Basic%20:">`, with the space written as `%20` | Yes (Vercel: Sensitive) | + +Then deploy the latest commit on Render (auto-deploy is off) and redeploy Production on Vercel; +both read the variables only at startup. A value that is not an HTTP(S) URL stops the API from +starting, so a failed Render deploy after this change points at the endpoint. To turn telemetry +off, delete `OTEL_EXPORTER_OTLP_ENDPOINT` on both and redeploy. To rotate the token, the +Grafana owner creates a new one, the platform owner replaces the header on both platforms and +redeploys, and the Grafana owner then deletes the old token. + +Verify, about 10 minutes, with a test account: + +- In Grafana, **Explore > Tempo**, a `specthread-web` trace from opening a project continues + into `specthread-api` and shows SQL spans; `service.version` is the deployed commit. +- The web root span's `client.address` is your own IP address, not a Vercel address. +- Opening an invitation link shows `/invites/[token]` and `/invites/{token}`, never the token. +- **Explore > Prometheus**: `http_server_request_duration_seconds` exists for `specthread-api`. +- **Explore > Loki**: API warnings and errors, if any, carry a `trace_id`. + ## 3. Post-Deployment Verification If signed-in pages fail or the Vercel logs show `Onboarding check failed (status 401)`, diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 0c4dc95..8cc239c 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,67 @@ # Shared handoff -## Current task: Connect a GitHub repository story (2026-10-30) +## Current task: Observability Phase 1, instrumentation off by default (2026-10-30) + +- Branch: `feature/observability`, from `main` at `ba2013f` (PR #24 merged). +- Scope: OpenTelemetry in the API and web app, exported to Grafana Cloud in deployment and + to an optional local viewer, off unless `OTEL_EXPORTER_OTLP_ENDPOINT` is set (ADR-055, + docs/OBSERVABILITY.md). Phase 2 (dashboards, alerts, Grafana Cloud setup) and Phase 3 + (load testing) are separate PRs. +- Completed: + - API: `Observability/ObservabilitySetup.cs`, one call in `Program.cs`. Traces (ASP.NET + Core, HttpClient, Npgsql), metrics (hosting, Kestrel, HttpClient, runtime, Npgsql), + logs (framework categories at Warning and above), OTLP/HTTP. `url.path` replaced by the + route template; `user.id` from the JWT `sub`; `/health` not traced; an endpoint that is + not an HTTP(S) URL stops startup. + - Web: `src/instrumentation.ts` and `lib/telemetry.ts` (`@vercel/otel` with a redacting + exporter: no raw paths or queries, URLs cut to scheme and host); trace context only to + the API origin; `client.address` from `x-real-ip` on Vercel; `user.id` on the page-gate + span in `proxy.ts`. + - Tests: fake OTLP receiver (`scripts/test-otlp.mjs`, 5126) started with the browser-test + stack, so the browser suite runs with telemetry on; `tests/api/telemetry.spec.ts` (API on + 5127/5128) and `tests/e2e/telemetry.spec.ts` guard tests; all test APIs blank `OTEL_*`. + - Local viewer: `compose.observability.yaml` (`grafana/otel-lgtm:0.38.1`, Grafana on 3030, + OTLP on 4318, loopback only); `npm run observability:up` / `observability:down`. + - Privacy page: a published "Operational telemetry" section (Grafana Labs, user ID, IP + address, 14 days); the rest of the page stays a draft. +- Decisions (team, recorded in ADR-055): Grafana Cloud free tier for all signals; Jacob owns + the Grafana stack, the Vercel/Render owner sets the variables; user ID only (no team ID); + full client IP on web traces only; privacy page names Grafana Labs and IP addresses; + Preview deployments send nothing; Sentry deferred. Assumption: two variables suffice + (protocol, service names, and environment are set in code or by the platform). No + `SpecThreadTelemetry` source/meter yet: there is no custom signal to put in it. +- Changed files: API `Program.cs`, `SpecThread.Api.csproj` and `packages.lock.json` + (OpenTelemetry 1.20.1 packages, Npgsql.OpenTelemetry 10.0.3), new `Observability/`; web + `package.json` (`@vercel/otel` 2.2.0, `@opentelemetry/api` 1.9.1, + `@opentelemetry/sdk-trace-base` 2.12.0), new `src/instrumentation.ts` and + `src/lib/telemetry.ts`, `proxy.ts`, `privacy/page.tsx`, `components/policy-page.tsx`, + `app-shell.css`; root `package.json`, `package-lock.json`, `playwright.config.ts`, + `compose.observability.yaml`, `render.yaml`, both `.env.example`; `scripts/test-otlp.mjs`, + `scripts/start-test-web.mjs`; tests `support/otlp.ts`, `support/api-process.ts`, + `api/telemetry.spec.ts`, `e2e/telemetry.spec.ts`, `e2e/landing.spec.ts`; docs new + OBSERVABILITY, DECISIONS (ADR-055), DEPLOYMENT (Telemetry section), TESTING, + ARCHITECTURE, AGENTS.md (one line on telemetry). `.gitignore` ignores `*_plan.md`. +- Checks: `npm test` 395 passed (builds the web app and Release API); `npm run lint`, + `npm run typecheck`, `dotnet format app/api --verify-no-changes --no-restore`, + `git diff --check` passed. The guard tests were seen to fail with redaction disabled on + both sides. The API image built and booted as in the `api-image` job (`/health` ok, + anonymous `/me` 401). Series budget: after the browser suite ran against the local + viewer, the API had sent about 2,100 metric series (limit 10,000). With telemetry off + the browser suite logs the same "destination stream closed early" errors from fault + tests, so they are not caused by this change. `npm audit` reports critical/high advisories + in `next`, `better-auth`, `sharp`, `zod`, and `source-map-js` that predate this branch; + none in the new packages. +- Known issues and risks: not exercised are Vercel's `x-real-ip` (read only on Vercel), + `@vercel/otel` flushing before a Vercel function freezes, real Grafana Cloud, and the API + sending nothing without an endpoint (nothing is registered; no receiver can observe it). + IP addresses and user IDs sent to Grafana Labs are personal data. Grafana Cloud's + Application Observability would add span-metric series against the same limit; leave it + off. The PR touches shared files (`Program.cs`, `playwright.config.ts`, `render.yaml`, + lockfiles): after it merges, teammates run `npm ci` and `dotnet restore app/api`. +- Next step: review and open the PR to `main`. After it merges and deploys, the Grafana + owner and platform owner follow docs/DEPLOYMENT.md "Telemetry (ADR-055)" and verify. + +## Previous task: Connect a GitHub repository story (2026-10-30, merged as PR #24) - Branch: `feature/repository-connection-story`, from `main` at `09848a9` (PR #23 merged). - Scope: the story "connect one GitHub repository to a project" (Danielle, Tech Lead; diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md new file mode 100644 index 0000000..56a91d4 --- /dev/null +++ b/docs/OBSERVABILITY.md @@ -0,0 +1,120 @@ +# Observability + +The web app and API can send traces, metrics, and logs over OpenTelemetry (OTLP/HTTP) to +Grafana Cloud in deployment, or to a local viewer while developing (ADR-055). Telemetry is +**off unless `OTEL_EXPORTER_OTLP_ENDPOINT` is set**, so local runs, tests, and CI behave as +if it did not exist. + +## What is recorded + +| Service | Signals | Highlights | +|---|---|---| +| `specthread-web` (Next.js, `@vercel/otel`) | Traces | One root span per request, named by route pattern (`GET /invites/[token]`); spans for outgoing `fetch` calls; `client.address` on Vercel; `user.id` on the page-gate span when signed in | +| `specthread-api` (ASP.NET Core, OpenTelemetry .NET) | Traces, metrics, logs | One span per request named by route template (`GET /projects/{projectId:guid}`) with `user.id`; a span per SQL statement (Npgsql) and per outgoing HTTP call (GitHub); request duration, Npgsql pool, Kestrel, HttpClient, and runtime metrics; `ILogger` warnings and errors from the framework, and every SpecThread log, linked to the trace | + +The web app passes the trace to the API in the `traceparent` header, so one page load is one +trace: web route, API route, SQL. Only the API origin (`SPECTHREAD_API_URL`) receives that +header. Every signal carries `service.version` (the deployed commit: `VERCEL_GIT_COMMIT_SHA` +on Vercel, `RENDER_GIT_COMMIT` on Render) and `deployment.environment.name`. + +`/health` requests are counted in metrics but not traced, because Render checks it every few +seconds. The web app sends traces only; its console output stays in Vercel's own logs, which +the Hobby plan keeps for about an hour. + +## Never recorded + +The guard tests (docs/TESTING.md) fail if any of these appear in what is sent. If a change +makes them fail, fix the redaction; do not loosen the test. + +- Tokens and secrets: invitation and password-reset tokens, JWTs, session cookies, API keys. +- Emails and names, including an email typed into a failed sign-in. +- Request or response bodies, document contents, and headers. +- Raw URL paths and query strings. The API replaces `url.path` with the route template (or + drops it when no route matched); the web app removes `http.target`, `url.path`, `url.query`, + and `resource.name`, and cuts every URL in span names, attributes, events, and status + messages down to its scheme and host. +- SQL parameter values (Npgsql leaves them out by default). +- User IDs, team IDs, or IP addresses as **metric** labels. Each distinct value is a separate + time series, and the free tier allows 10,000. They belong on traces only. + +The caller is identified by `user.id`, the Better Auth user ID from the JWT `sub` (API) or the +session (web). IP addresses appear only as `client.address` on web spans, from Vercel's +`x-real-ip` header, which `@vercel/otel` reads only when running on Vercel. Both are personal +data: they are kept as long as the backend's retention (14 days on Grafana Cloud's free tier) +and the privacy page says so. + +## Local viewer + +Optional. Requires Docker Desktop, which `npm test` already needs. + +```sh +npm run observability:up # grafana/otel-lgtm: Grafana, Tempo, Loki, Prometheus +``` + +Open Grafana at http://localhost:3030. Point your dev servers at it, then restart them: + +```sh +# Web: add to app/web/.env.local +OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 +# API: once +dotnet user-secrets set "OTEL_EXPORTER_OTLP_ENDPOINT" "http://127.0.0.1:4318" --project app/api +``` + +Load a page, then in Grafana open **Explore > Tempo** and search for service `specthread-web`. +Metrics are under **Explore > Prometheus** (exported every 60 seconds), and API logs under +**Explore > Loki**. When finished, `npm run observability:down`; nothing is kept. Leaving the +two settings in place is harmless: while the viewer is down the dev servers run normally and +their exports fail quietly. Remove them to stop sending. + +While developing, open the trace of the page you changed: check how many queries it runs and in +what order, and that nothing from the list above appears in its attributes. + +## Deployed telemetry + +Grafana Cloud's free tier receives production telemetry. The Grafana owner holds the stack and +its tokens; the platform owner sets the variables in DEPLOYMENT.md on Vercel (Production only) +and Render. Rollback: delete `OTEL_EXPORTER_OTLP_ENDPOINT` on both platforms and redeploy. + +| Variable | Value | +|---|---| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | The stack's OTLP endpoint, for example `https://otlp-gateway-prod-us-west-0.grafana.net/otlp`. Empty or unset turns telemetry off. On the API, any other value that is not an HTTP(S) URL stops startup | +| `OTEL_EXPORTER_OTLP_HEADERS` | `Authorization=Basic%20`. **Secret.** The space is written `%20` | + +Nothing else is needed: the protocol is always OTLP over HTTP with protobuf, and service names +and environment come from the code and the platform. + +**Metric series budget.** The free tier allows 10,000 active series. After the whole browser +suite ran against the local viewer (2026-10-30), the API had sent about 2,100; the web app +sends no metrics. The local viewer also builds about 5,000 series of its own from traces +(`traces_spanmetrics_*`). Grafana Cloud builds those only when Application Observability is +turned on, which would count against the same limit, so leave it off unless the budget is +checked again. If a change adds instruments or many routes, recheck with +`count({__name__=~".+"})` in the local viewer. + +## Finding what an account or IP did + +In Grafana, **Explore > Tempo**, query type TraceQL: + +```text +{ span.user.id = "" } # everything one user did +{ span.client.address = "203.0.113.7" } # everything from one IP +{ resource.service.name = "specthread-api" && span.http.response.status_code >= 500 } +``` + +Traces hold only the user ID. To see whose it is, someone with database access runs, in the +Supabase SQL editor, `select id, email, name from "user" where id = '';` (or +`where email = '...'` to go the other way). Exports of traces (Grafana's Download as JSON) +contain IDs and IP addresses: keep them team-only. + +## Adding code + +- Instrumentation is automatic: new routes, pages, SQL, and `HttpClient` or `fetch` calls are + traced with no extra code. Add a manual span only for meaningful work that is not an HTTP or + SQL call. +- The first custom API signal adds an `ActivitySource` and `Meter` named `SpecThread.Api` in + `app/api/Observability` and registers them with `AddSource` and `AddMeter`. Name metrics + `specthread..` with a unit (for example `specthread.evidence.refresh.duration` + in seconds). Span attributes for product IDs are named `specthread._id` and never go + on metrics. +- PR checklist: if a change adds a route, an external call, or background work, open its trace + locally. diff --git a/docs/TESTING.md b/docs/TESTING.md index 5d84cf1..c8195c1 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -83,7 +83,13 @@ real GitHub. The repository API tests use their own stand-in on 5109 and API instances on 5110 and 5111; the evidence API tests use 5113 and 5114, and the review API tests 5115. Test API instances blank the GitHub settings unless a test sets them, so a developer's -user-secrets never reach them. A test calls `failApi` from tests/support/api-faults.ts to make the +user-secrets never reach them. They blank `OTEL_EXPORTER_OTLP_ENDPOINT` and +`OTEL_EXPORTER_OTLP_HEADERS` the same way, so telemetry is off unless a test turns it on. +The browser-test web app and its API (5106) do turn it on: scripts/start-test-web.mjs starts +a fake OTLP receiver on 5126 (scripts/test-otlp.mjs), so the browser suite runs with +telemetry on, and tests read back what arrived with tests/support/otlp.ts. The telemetry API +tests start their own API on 5127 (and 5128 for a startup failure) sending to that receiver. +A test calls `failApi` from tests/support/api-faults.ts to make the API fail, drop the connection, answer with a wrong body, or respond slowly for that test's own user only, so parallel tests are unaffected. Keep these ports free; existing servers are not reused. Do not run competing Next.js builds @@ -306,6 +312,18 @@ currently require network access during the web build. with seeded evidence and an acceptance, the empty state, 390px without horizontal scrolling, and a failed save keeping the label. Not exercised: 20 follow-ups through the browser, and an Evidence complete requirement fed by the fake GitHub. +- Telemetry (ADR-055). tests/api/telemetry.spec.ts (API on 5127): spans named by route + template with the caller's `user.id`; an invitation token in the path and query, a token + in an unmatched path, and the bearer token never sent; the error log linked by trace ID; + request metrics by route; `/health` not traced; the credentials header arriving decoded; + an endpoint that is not an HTTP(S) URL stopping startup; and the web redaction and header + parsing functions directly. tests/e2e/telemetry.spec.ts: an invitation link is one trace + across web and API without its token; a signed-in page carries the user's ID on web and API + spans, never their email, name, or session token. Each test starts its own trace with a + `traceparent` header. Not exercised: Vercel's `x-real-ip` (read only on Vercel), real + Grafana Cloud, and the API sending nothing when the endpoint is unset (nothing is + registered then, and there is no receiver to observe the absence). The privacy page test in + tests/e2e/landing.spec.ts checks the telemetry disclosure. - Last editor, archive view, and restore (ADR-050): tests/schema/product-api.spec.ts checks `updatedBy` after create and after another member's save; default and `archived=true` lists; an archived requirement readable but refusing saves and diff --git a/package-lock.json b/package-lock.json index 39a9ccc..4676f2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,9 @@ "version": "0.1.0", "dependencies": { "@better-auth/infra": "0.4.9", + "@opentelemetry/api": "1.9.1", + "@opentelemetry/sdk-trace-base": "2.12.0", + "@vercel/otel": "2.2.0", "better-auth": "1.7.5", "lucide-react": "^0.563.0", "next": "16.3.5", @@ -1543,6 +1546,149 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.223.0.tgz", + "integrity": "sha512-Oepm6etVPFCn0VWB1t833iOTgz1eYBDUPxcrOR/XvwzD1+Ag9VzSPSrrKAFNJ/haatj+8CXbG47pZXvdNQKDJw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.12.0.tgz", + "integrity": "sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.223.0.tgz", + "integrity": "sha512-tttBo88TbwK1zpsH6VeJoxLQopxOjW3cnrP50fsQJRZftkSKDDwjolXXBvow2jy0LKXlNRkDQh0NT6yI/q+BEQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/api-logs": "0.223.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.12.0.tgz", + "integrity": "sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.223.0.tgz", + "integrity": "sha512-ApiAfUiUh6fmJt1GbsBuoeDn8D8IL42UvGyPUr8o1Z89ZeIiOCE1rZ78KkIUjEX3OV8Oorl9ez5Qjo43ZynDyw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/api-logs": "0.223.0", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.12.0.tgz", + "integrity": "sha512-mVMaEHwCUJuY3S4Esn+nT0yQA3qCEGh+SOPJsXygU0Gl453WcsDNjtEa8EfdewMwoLyr1tlofB4fXytVpiXuow==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.12.0.tgz", + "integrity": "sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.12.0.tgz", + "integrity": "sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-trace": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", @@ -2558,6 +2704,24 @@ "win32" ] }, + "node_modules/@vercel/otel": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@vercel/otel/-/otel-2.2.0.tgz", + "integrity": "sha512-MP5wQS3Wh/Ly6WAvINE98UOk8JgK4mwWIMW9oEaMxmsnVt8fQWp8D86Ps2nVAbHDkdiI+Xxw5RV49Yz6XyqCOg==", + "license": "MIT", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <2.0.0", + "@opentelemetry/api-logs": ">=0.200.0 <0.300.0", + "@opentelemetry/instrumentation": ">=0.200.0 <0.300.0", + "@opentelemetry/resources": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-logs": ">=0.200.0 <0.300.0", + "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.9.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", @@ -3157,6 +3321,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cjs-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", + "integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==", + "license": "MIT", + "peer": true + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -3284,7 +3455,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3549,6 +3719,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-3.0.4.tgz", + "integrity": "sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==", + "license": "MIT", + "peer": true + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4518,6 +4695,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.3.tgz", + "integrity": "sha512-usm/IiY63VtOyx/dm7uEoZV+EsanCDmwRUroQZIkFLGu1NVXZ8mZqIOhaN4QIJ7EsbMTuQbJXwCySCx+ozMA8g==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.3", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -5560,11 +5752,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT", + "peer": true + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6305,6 +6503,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", diff --git a/package.json b/package.json index bc9dde9..472ca7c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "test": "playwright test", "test:ui": "playwright test --ui", "test:report": "playwright show-report", - "schema:generate": "node scripts/generate-initial-schema.mjs" + "schema:generate": "node scripts/generate-initial-schema.mjs", + "observability:up": "docker compose -f compose.observability.yaml up --detach", + "observability:down": "docker compose -f compose.observability.yaml down" }, "devDependencies": { "@playwright/test": "1.63.0", diff --git a/playwright.config.ts b/playwright.config.ts index d878a8d..7b71c0b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -75,7 +75,11 @@ export default defineConfig({ url: "http://127.0.0.1:5100/health", reuseExistingServer: false, timeout: 120_000, - env: { ConnectionStrings__Database: "", Auth__Issuer: "http://127.0.0.1:5101" }, + env: { + ConnectionStrings__Database: "", Auth__Issuer: "http://127.0.0.1:5101", + // Telemetry off, even when a developer's shell or user-secrets point it at a local viewer. + OTEL_EXPORTER_OTLP_ENDPOINT: "", OTEL_EXPORTER_OTLP_HEADERS: "", + }, }, ], }); diff --git a/render.yaml b/render.yaml index 11290d4..c21dc79 100644 --- a/render.yaml +++ b/render.yaml @@ -16,3 +16,8 @@ services: sync: false - key: Auth__Issuer sync: false + # Optional telemetry (ADR-055, docs/DEPLOYMENT.md); empty means off. + - key: OTEL_EXPORTER_OTLP_ENDPOINT + sync: false + - key: OTEL_EXPORTER_OTLP_HEADERS + sync: false diff --git a/scripts/start-test-web.mjs b/scripts/start-test-web.mjs index 62bb9f8..b228a23 100644 --- a/scripts/start-test-web.mjs +++ b/scripts/start-test-web.mjs @@ -5,6 +5,7 @@ import { startTestDatabase } from "./test-database.mjs"; import buildApi from "./build-api.mjs"; import { startApiProxy } from "./test-api-proxy.mjs"; import { startFakeGitHub } from "./test-github.mjs"; +import { startFakeOtlp } from "./test-otlp.mjs"; // Playwright starts webServer entries in order, before globalSetup. // Build here before the second entry starts the API and locks its DLL on Windows. @@ -17,6 +18,14 @@ const proxyOrigin = "http://127.0.0.1:5107"; const gitHubOrigin = "http://127.0.0.1:5108"; const gitHubAppId = "424242"; const gitHubKey = generateKeyPairSync("rsa", { modulusLength: 2048 }); +// The browser-test web app and API send telemetry to a fake receiver on 5126, so the browser suite +// runs with telemetry on and tests can read back what was sent. Never a developer's own viewer. +const telemetry = { + OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:5126", + OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Basic%20dGVzdA==", + OTEL_BSP_SCHEDULE_DELAY: "200", + OTEL_BLRP_SCHEDULE_DELAY: "200", +}; let database; let api; let server; @@ -40,9 +49,11 @@ try { await mkdir("playwright/.cache", { recursive: true }); await writeFile("playwright/.cache/test-web-database.json", JSON.stringify({ name: database.name })); const url = new URL(database.connectionString); + await startFakeOtlp({ port: 5126 }); api = spawn("dotnet", ["app/api/bin/Release/net10.0/SpecThread.Api.dll", "--urls", apiOrigin, "--environment", "Development"], { env: { ...process.env, + ...telemetry, ConnectionStrings__Database: `Host=${url.hostname};Port=${url.port};Database=postgres;Username=${url.username};Password=${url.password}`, Auth__Issuer: process.env.BETTER_AUTH_URL, GitHub__ApiBaseUrl: gitHubOrigin, @@ -56,7 +67,7 @@ try { api.on("error", () => void stop(1)); await startApiProxy({ port: 5107, upstream: apiOrigin }); await startFakeGitHub({ port: 5108, appId: gitHubAppId, publicKey: gitHubKey.publicKey }); - const env = { ...process.env, DATABASE_URL: database.connectionString, SPECTHREAD_API_URL: proxyOrigin }; + const env = { ...process.env, ...telemetry, DATABASE_URL: database.connectionString, SPECTHREAD_API_URL: proxyOrigin }; execFileSync(process.execPath, ["../../node_modules/next/dist/bin/next", "build"], { cwd: "app/web", env, stdio: "inherit" }); for (let attempt = 0; ; attempt++) { if (api.exitCode !== null) throw new Error("The browser-test API exited before it was ready."); diff --git a/scripts/test-otlp.mjs b/scripts/test-otlp.mjs new file mode 100644 index 0000000..0eb2bfc --- /dev/null +++ b/scripts/test-otlp.mjs @@ -0,0 +1,48 @@ +import { createServer } from "node:http"; + +// Test-only OTLP/HTTP receiver. The test web app and API send telemetry here, and tests read +// back what arrived, so they can check that a trace crosses web -> API and that no secret is +// ever sent. OTLP protobuf stores strings as raw UTF-8 and IDs as raw bytes, so tests search the +// bytes without decoding them. +// +// POST /v1/traces | /v1/metrics | /v1/logs (from the OpenTelemetry exporters) +// GET /__otlp/requests?contains=&... requests whose body contains every given byte string, +// as [{ path, authorization, body (base64) }] +// +// Only the most recent requests are kept, which is plenty for one test run. + +const limit = 5000; + +export function startFakeOtlp({ port }) { + const received = []; + const server = createServer(async (request, response) => { + try { + const url = new URL(request.url, "http://otlp"); + if (request.method === "POST" && url.pathname.startsWith("/v1/")) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + received.push({ path: url.pathname, authorization: request.headers.authorization ?? null, body: Buffer.concat(chunks) }); + if (received.length > limit) received.splice(0, received.length - limit); + // An empty body is a valid, empty Export*ServiceResponse. + response.writeHead(200, { "content-type": "application/x-protobuf" }).end(); + return; + } + if (request.method === "GET" && url.pathname === "/__otlp/requests") { + const needles = url.searchParams.getAll("contains").map(hex => Buffer.from(hex, "hex")); + const matches = received.filter(({ body }) => needles.every(needle => body.includes(needle))); + response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify( + matches.map(({ path, authorization, body }) => ({ path, authorization, body: body.toString("base64") })))); + return; + } + response.writeHead(404).end(); + } catch (error) { + console.error("Fake OTLP receiver failed", error); + if (!response.headersSent) response.writeHead(500); + response.end(); + } + }); + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => resolve(server)); + }); +} diff --git a/tests/api/telemetry.spec.ts b/tests/api/telemetry.spec.ts new file mode 100644 index 0000000..8cbef3a --- /dev/null +++ b/tests/api/telemetry.spec.ts @@ -0,0 +1,81 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; +import { randomBytes, randomUUID } from "node:crypto"; +import { parseHeaders, redactAttributes, redactText } from "../../app/web/src/lib/telemetry"; +import { startApi } from "../support/api-process"; +import { newTrace, received, testAuthorization } from "../support/otlp"; + +// An API on 5127 sends telemetry to the fake receiver on 5126, which scripts/start-test-web.mjs +// starts for every run. It has no database, so database-backed routes answer 500. +const telemetry = { + Auth__Issuer: "http://127.0.0.1:5101", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:5126", + OTEL_EXPORTER_OTLP_HEADERS: "Authorization=Basic%20dGVzdA==", + OTEL_BSP_SCHEDULE_DELAY: "200", + OTEL_BLRP_SCHEDULE_DELAY: "200", + OTEL_METRIC_EXPORT_INTERVAL: "500", +}; + +let api: Awaited>; +test.beforeAll(async () => { api = await startApi(5127, telemetry); }); +test.afterAll(() => api?.stop()); + +async function mint(request: APIRequestContext, sub: string) { + const response = await request.post("http://127.0.0.1:5101/sign", { data: { claims: { sub } } }); + return (await response.json()).token as string; +} + +test("requests are traced by route with the caller's ID, and secrets in the path or token never leave", async ({ request }) => { + const secret = randomBytes(32).toString("hex"); + const userId = `telemetry-${randomUUID()}`; + const token = await mint(request, userId); + const health = newTrace(); + const me = newTrace(); + const invite = newTrace(); + const unknown = newTrace(); + + expect((await request.get(`${api.baseURL}/health`, { headers: { traceparent: health.header } })).status()).toBe(200); + expect((await request.get(`${api.baseURL}/me`, { headers: { authorization: `Bearer ${token}`, traceparent: me.header } })).status()).toBe(200); + expect((await request.get(`${api.baseURL}/invites/${secret}?token=${secret}`, { headers: { traceparent: invite.header } })).status()).toBe(500); + expect((await request.get(`${api.baseURL}/unknown/${secret}`, { headers: { traceparent: unknown.header } })).status()).toBe(401); + + // Each span is named by its route and carries the route template, never the raw path. + await expect.poll(async () => (await received(me.bytes, "GET /me", "user.id", userId, "specthread-api")).length).toBeGreaterThan(0); + await expect.poll(async () => (await received(invite.bytes, "GET /invites/{token}")).length).toBeGreaterThan(0); + await expect.poll(async () => (await received(unknown.bytes, "specthread-api")).length).toBeGreaterThan(0); + // The failure's error log carries the same trace ID, so it links to the span. + await expect.poll(async () => (await received(invite.bytes)).some(({ path }) => path === "/v1/logs")).toBe(true); + // Request metrics arrive, labelled by route. + await expect.poll(async () => (await received("http.server.request.duration", "/me")).some(({ path }) => path === "/v1/metrics")).toBe(true); + + expect(await received(secret)).toEqual([]); + expect(await received(token)).toEqual([]); + expect(await received(token.split(".")[2])).toEqual([]); + // Health checks are counted in metrics but not traced. + expect(await received(health.bytes)).toEqual([]); + // OTEL_EXPORTER_OTLP_HEADERS reaches the backend, percent-decoded. + for (const { authorization } of await received(invite.bytes)) expect(authorization).toBe(testAuthorization); +}); + +test("web spans keep only the scheme and host of URLs and drop raw paths and queries", () => { + expect(redactText("fetch GET https://api.example/invites/abc?token=def#x")).toBe("fetch GET https://api.example"); + expect(redactText("failed: http://user:pass@127.0.0.1:5107/projects/1 (ECONNREFUSED)")).toBe("failed: http://127.0.0.1:5107 (ECONNREFUSED)"); + expect(redactText("GET /invites/[token]")).toBe("GET /invites/[token]"); + expect(redactAttributes({ + "http.target": "/invites/abc", "url.path": "/invites/abc", "url.query": "?token=abc", "resource.name": "/invites/abc", + "http.url": "https://api.example/invites/abc", "next.route": "/invites/[token]", "http.status_code": 200, + "list": ["https://a.example/x?y", "plain"], + })).toEqual({ + "http.url": "https://api.example", "next.route": "/invites/[token]", "http.status_code": 200, + "list": ["https://a.example", "plain"], + }); +}); + +test("OTEL_EXPORTER_OTLP_HEADERS is read as percent-encoded name=value pairs", () => { + expect(parseHeaders("Authorization=Basic%20abc%3D%3D, X-Scope=team")).toEqual({ Authorization: "Basic abc==", "X-Scope": "team" }); + expect(parseHeaders(undefined)).toEqual({}); + expect(parseHeaders("broken,=nothing")).toEqual({}); +}); + +test("an endpoint that is not an HTTP(S) URL stops the API from starting", async () => { + await expect(startApi(5128, { ...telemetry, OTEL_EXPORTER_OTLP_ENDPOINT: "127.0.0.1:5126" })).rejects.toThrow(/exited/); +}); diff --git a/tests/e2e/landing.spec.ts b/tests/e2e/landing.spec.ts index 427a0b1..e5abefb 100644 --- a/tests/e2e/landing.spec.ts +++ b/tests/e2e/landing.spec.ts @@ -39,6 +39,11 @@ test("privacy and terms are public pages without the app sidebar", async ({ page await page.getByRole("contentinfo").getByRole("link", { name: "Privacy" }).click(); await expect(page).toHaveURL(/\/privacy$/); await expect(page.getByRole("heading", { name: "Privacy", level: 1 })).toBeVisible(); + // Telemetry is disclosed (ADR-055): the processor, what identifies a person, and retention. + const telemetry = page.getByRole("region", { name: "Operational telemetry" }); + await expect(telemetry).toContainText("Grafana Labs"); + await expect(telemetry).toContainText("IP address"); + await expect(telemetry).toContainText("14 days"); await expect(page.locator(".app-sidebar")).toHaveCount(0); await page.goto("/terms"); await expect(page.getByRole("heading", { name: "Terms of use", level: 1 })).toBeVisible(); diff --git a/tests/e2e/telemetry.spec.ts b/tests/e2e/telemetry.spec.ts new file mode 100644 index 0000000..c7e12db --- /dev/null +++ b/tests/e2e/telemetry.spec.ts @@ -0,0 +1,40 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { newTrace, received, testAuthorization } from "../support/otlp"; +import { createTestSession, expect, test } from "./fixtures"; + +// The browser-test web app and API send telemetry to the fake receiver on 5126 +// (scripts/start-test-web.mjs). Each test starts its own trace with a traceparent header and +// finds its spans by that trace ID. +test.use({ signedIn: false }); + +test("an invitation link is one trace from web to API, and its token is never sent", async ({ page }) => { + const secret = randomBytes(32).toString("hex"); + const trace = newTrace(); + await page.setExtraHTTPHeaders({ traceparent: trace.header }); + await page.goto(`/invites/${secret}`); + + await expect.poll(async () => (await received(trace.bytes, "specthread-web", "/invites/[token]")).length).toBeGreaterThan(0); + await expect.poll(async () => (await received(trace.bytes, "specthread-api", "GET /invites/{token}")).length).toBeGreaterThan(0); + expect(await received(secret)).toEqual([]); + for (const { authorization } of await received(trace.bytes)) expect(authorization).toBe(testAuthorization); +}); + +test("a signed-in page records the user by ID, never by email, name, or session token", async ({ context, page }) => { + const name = `Telemetry ${randomUUID()}`; + const session = await createTestSession(name); + try { + await context.addCookies([session.cookie]); + const trace = newTrace(); + await page.setExtraHTTPHeaders({ traceparent: trace.header }); + await page.goto("/teams"); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + + await expect.poll(async () => (await received(trace.bytes, "specthread-web", "user.id", session.userId)).length).toBeGreaterThan(0); + await expect.poll(async () => (await received(trace.bytes, "specthread-api", "user.id", session.userId)).length).toBeGreaterThan(0); + expect(await received(`${session.userId}@example.invalid`)).toEqual([]); + expect(await received(name)).toEqual([]); + expect(await received(session.cookie.value.split(".")[0])).toEqual([]); + } finally { + await session.remove(); + } +}); diff --git a/tests/support/api-process.ts b/tests/support/api-process.ts index 0eef0d9..3a31035 100644 --- a/tests/support/api-process.ts +++ b/tests/support/api-process.ts @@ -10,7 +10,8 @@ export async function startApi(port: number, env: Record) { // Blank by default, so a developer's own user-secrets never reach a test API. env: { ...process.env, ConnectionStrings__Database: "", Auth__Issuer: "", - GitHub__AppId: "", GitHub__PrivateKey: "", GitHub__AppSlug: "", ...env, + GitHub__AppId: "", GitHub__PrivateKey: "", GitHub__AppSlug: "", + OTEL_EXPORTER_OTLP_ENDPOINT: "", OTEL_EXPORTER_OTLP_HEADERS: "", ...env, }, stdio: "ignore", }); diff --git a/tests/support/otlp.ts b/tests/support/otlp.ts new file mode 100644 index 0000000..a937cd3 --- /dev/null +++ b/tests/support/otlp.ts @@ -0,0 +1,25 @@ +import { randomBytes } from "node:crypto"; + +// Reads what the fake OTLP receiver (scripts/test-otlp.mjs, port 5126) has received. +const receiver = "http://127.0.0.1:5126/__otlp/requests"; + +export type OtlpRequest = { path: string; authorization: string | null; body: Buffer }; + +/** A W3C traceparent header with a fresh trace ID, so a test can find its own spans and logs. */ +export function newTrace() { + const traceId = randomBytes(16).toString("hex"); + return { traceId, header: `00-${traceId}-${randomBytes(8).toString("hex")}-01`, bytes: Buffer.from(traceId, "hex") }; +} + +/** Requests whose body contains every needle. Strings are searched as UTF-8, as OTLP protobuf stores them. */ +export async function received(...needles: (string | Buffer)[]): Promise { + const query = new URLSearchParams(needles.map(needle => + ["contains", (typeof needle === "string" ? Buffer.from(needle) : needle).toString("hex")])); + const response = await fetch(`${receiver}?${query}`); + if (!response.ok) throw new Error(`The fake OTLP receiver answered ${response.status}.`); + const requests = await response.json() as { path: string; authorization: string | null; body: string }[]; + return requests.map(request => ({ ...request, body: Buffer.from(request.body, "base64") })); +} + +/** The test value of OTEL_EXPORTER_OTLP_HEADERS ("Authorization=Basic%20dGVzdA=="), as it should arrive. */ +export const testAuthorization = "Basic dGVzdA=="; From 69547ed4c2941d68e446aaa7cbaaa55d547ecadf Mon Sep 17 00:00:00 2001 From: Jacob Heminway Date: Fri, 30 Oct 2026 19:52:43 -0400 Subject: [PATCH 2/2] fix: windows and linux handling API crashes differently, causing CI tests to fail --- tests/support/api-process.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/support/api-process.ts b/tests/support/api-process.ts index 3a31035..ad66be4 100644 --- a/tests/support/api-process.ts +++ b/tests/support/api-process.ts @@ -18,7 +18,8 @@ export async function startApi(port: number, env: Record) { const stop = () => { child.kill(); }; try { for (let attempt = 0; ; attempt++) { - if (child.exitCode !== null) throw new Error(`API exited with code ${child.exitCode}`); + // On Linux an unhandled startup exception aborts the process, which sets signalCode, not exitCode. + if (child.exitCode !== null || child.signalCode !== null) throw new Error(`API exited with ${child.exitCode ?? child.signalCode}`); try { if ((await fetch(`${baseURL}/health`)).ok) break; } catch (error) {