From 08f9273ded379106664d458ed440310169c5d6b5 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sun, 14 Jun 2026 19:53:12 +0100 Subject: [PATCH 1/5] feat(otel): Add distributed tracing across app and workspace server Generated-By: PostHog Code --- apps/code/package.json | 6 + apps/code/src/main/index.ts | 3 + .../main/services/app-lifecycle/service.ts | 7 + apps/code/src/main/trpc/trpc.ts | 10 +- apps/code/src/main/utils/otel-trace.test.ts | 125 ++++++++++ apps/code/src/main/utils/otel-trace.ts | 30 +++ apps/code/src/renderer/main.tsx | 22 +- apps/code/src/renderer/utils/otel-trace.ts | 130 ++++++++++ packages/shared/src/constants.ts | 2 + packages/workspace-client/package.json | 1 + packages/workspace-client/src/client.ts | 11 +- packages/workspace-server/package.json | 6 + packages/workspace-server/src/app.ts | 16 ++ packages/workspace-server/src/node-tracing.ts | 113 +++++++++ packages/workspace-server/src/otel-trace.ts | 25 ++ packages/workspace-server/src/serve.ts | 4 + .../src/services/agent/agent.ts | 100 ++++---- .../src/services/git/service.ts | 24 +- packages/workspace-server/src/trpc.ts | 224 +++++++++--------- .../workspace-server/src/with-span.test.ts | 62 +++++ packages/workspace-server/src/with-span.ts | 25 ++ pnpm-lock.yaml | 153 ++++++++++++ 22 files changed, 931 insertions(+), 168 deletions(-) create mode 100644 apps/code/src/main/utils/otel-trace.test.ts create mode 100644 apps/code/src/main/utils/otel-trace.ts create mode 100644 apps/code/src/renderer/utils/otel-trace.ts create mode 100644 packages/workspace-server/src/node-tracing.ts create mode 100644 packages/workspace-server/src/otel-trace.ts create mode 100644 packages/workspace-server/src/with-span.test.ts create mode 100644 packages/workspace-server/src/with-span.ts diff --git a/apps/code/package.json b/apps/code/package.json index d3389fad6..9b1b086f4 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -92,10 +92,16 @@ "@inversifyjs/strongly-typed": "2.2.0", "@json-render/core": "^0.19.0", "@modelcontextprotocol/sdk": "^1.12.1", + "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/context-zone": "^2.5.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.5.0", "@opentelemetry/sdk-logs": "^0.208.0", + "@opentelemetry/sdk-trace-base": "^2.5.0", + "@opentelemetry/sdk-trace-node": "^2.5.0", + "@opentelemetry/sdk-trace-web": "^2.5.0", "@opentelemetry/semantic-conventions": "^1.39.0", "@parcel/watcher": "^2.5.6", "@phosphor-icons/react": "^2.1.10", diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 4f17c0404..09f3889c8 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -66,6 +66,7 @@ import { readChromiumLogTail, } from "./utils/logger"; import { isMacosPackagedUnsafeBundleLocation } from "./utils/macos-packaged-install-guard"; +import { initOtelTracing } from "./utils/otel-trace"; import { createWindow } from "./window"; type FileWatcherEventsByKind = { @@ -101,6 +102,8 @@ export class FileWatcherBridge extends TypedEventEmitter { return result; }); +const tracingMonitor = middleware(({ path, next, type }) => + traceTrpcCall(getMainTracer(), path, type, next), +); + export const router = baseRouter; -export const publicProcedure = baseProcedure.use(callRateMonitor); +export const publicProcedure = baseProcedure + .use(callRateMonitor) + .use(tracingMonitor); export { middleware }; diff --git a/apps/code/src/main/utils/otel-trace.test.ts b/apps/code/src/main/utils/otel-trace.test.ts new file mode 100644 index 000000000..7b17fd829 --- /dev/null +++ b/apps/code/src/main/utils/otel-trace.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetTracer = vi.fn(() => ({}) as unknown); +const mockRegister = vi.fn(); +const mockForceFlush = vi.fn(() => Promise.resolve()); +const mockShutdown = vi.fn(() => Promise.resolve()); + +vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ + OTLPTraceExporter: class { + constructor(public config: unknown) {} + }, +})); + +vi.mock("@opentelemetry/sdk-trace-base", () => ({ + BatchSpanProcessor: class { + constructor( + public _exporter: unknown, + public _opts: unknown, + ) {} + }, + ParentBasedSampler: class { + constructor(public _opts: unknown) {} + }, + TraceIdRatioBasedSampler: class { + constructor(public _ratio: number) {} + }, +})); + +vi.mock("@opentelemetry/sdk-trace-node", () => ({ + NodeTracerProvider: class { + constructor(public _opts: unknown) {} + register() { + return mockRegister(); + } + getTracer() { + return mockGetTracer(); + } + forceFlush() { + return mockForceFlush(); + } + shutdown() { + return mockShutdown(); + } + }, +})); + +vi.mock("@opentelemetry/resources", () => ({ + resourceFromAttributes: vi.fn((attrs: Record) => attrs), +})); + +vi.mock("@opentelemetry/semantic-conventions", () => ({ + ATTR_SERVICE_NAME: "service.name", + ATTR_SERVICE_VERSION: "service.version", +})); + +describe("otel-trace", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + process.env.POSTHOG_CODE_VERSION = "1.0.0-test"; + }); + + describe("initOtelTracing", () => { + it.each([ + ["key missing", "", "https://test.posthog.com"], + ["host missing", "phc_test123", ""], + ["host invalid", "phc_test123", "not a url"], + ])("returns null when %s", async (_case, key, host) => { + vi.stubEnv("VITE_POSTHOG_API_KEY", key); + vi.stubEnv("VITE_POSTHOG_API_HOST", host); + + const { initOtelTracing, getMainTracer } = await import( + "@main/utils/otel-trace" + ); + + expect(initOtelTracing()).toBeNull(); + expect(getMainTracer()).toBeNull(); + expect(mockRegister).not.toHaveBeenCalled(); + }); + + it("registers a provider and returns a tracer when configured", async () => { + vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test123"); + vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com"); + + const { initOtelTracing, getMainTracer } = await import( + "@main/utils/otel-trace" + ); + const tracer = initOtelTracing(); + + expect(tracer).not.toBeNull(); + expect(getMainTracer()).toBe(tracer); + expect(mockRegister).toHaveBeenCalled(); + }); + }); + + describe("shutdownOtelTracing", () => { + it("flushes and shuts down the provider", async () => { + vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test123"); + vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com"); + + const { initOtelTracing, shutdownOtelTracing } = await import( + "@main/utils/otel-trace" + ); + initOtelTracing(); + + await shutdownOtelTracing(); + + expect(mockForceFlush).toHaveBeenCalled(); + expect(mockShutdown).toHaveBeenCalled(); + }); + + it("is a no-op when provider was never created", async () => { + vi.stubEnv("VITE_POSTHOG_API_KEY", ""); + + const { initOtelTracing, shutdownOtelTracing } = await import( + "@main/utils/otel-trace" + ); + initOtelTracing(); + + await expect(shutdownOtelTracing()).resolves.toBeUndefined(); + expect(mockForceFlush).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/code/src/main/utils/otel-trace.ts b/apps/code/src/main/utils/otel-trace.ts new file mode 100644 index 000000000..32bad4e14 --- /dev/null +++ b/apps/code/src/main/utils/otel-trace.ts @@ -0,0 +1,30 @@ +import type { Tracer } from "@opentelemetry/api"; +import { + initNodeTracing, + type NodeTracing, +} from "@posthog/workspace-server/node-tracing"; +import { getAppVersion } from "./env"; + +let current: NodeTracing | null = null; + +export function initOtelTracing(): Tracer | null { + current = initNodeTracing({ + serviceName: "posthog-code-desktop", + serviceVersion: getAppVersion(), + attributes: { + "service.namespace": "ipc", + "process.runtime.name": "electron", + "process.runtime.version": process.versions.electron, + }, + }); + return current?.tracer ?? null; +} + +export function getMainTracer(): Tracer | null { + return current?.tracer ?? null; +} + +export async function shutdownOtelTracing(): Promise { + await current?.shutdown(); + current = null; +} diff --git a/apps/code/src/renderer/main.tsx b/apps/code/src/renderer/main.tsx index dfd059c3d..96ae9ec6d 100644 --- a/apps/code/src/renderer/main.tsx +++ b/apps/code/src/renderer/main.tsx @@ -15,10 +15,16 @@ import { Providers } from "@components/Providers"; import { preloadHighlighter } from "@pierre/diffs"; import { boot } from "@posthog/di/contribution"; import { ServiceProvider } from "@posthog/di/react"; +import { router } from "@posthog/ui/router/router"; import App from "@posthog/ui/shell/App"; import { initializePostHog } from "@posthog/ui/shell/posthogAnalyticsImpl"; import { registerDesktopContributions } from "@renderer/desktop-contributions"; import { container } from "@renderer/di/container"; +import { + initOtelTracing, + onAppRender, + traceNavigations, +} from "@renderer/utils/otel-trace"; import "@renderer/desktop-services"; import React from "react"; import ReactDOM from "react-dom/client"; @@ -80,6 +86,10 @@ if (bootstrapSessionId) { initializePostHog(bootstrapSessionId); } +if (initOtelTracing()) { + traceNavigations(router); +} + registerDesktopContributions(); void boot(container); @@ -88,10 +98,12 @@ if (!rootElement) throw new Error("Root element not found"); ReactDOM.createRoot(rootElement).render( - - - - - + + + + + + + , ); diff --git a/apps/code/src/renderer/utils/otel-trace.ts b/apps/code/src/renderer/utils/otel-trace.ts new file mode 100644 index 000000000..e8f867769 --- /dev/null +++ b/apps/code/src/renderer/utils/otel-trace.ts @@ -0,0 +1,130 @@ +import type { Span, Tracer } from "@opentelemetry/api"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { + BatchSpanProcessor, + ParentBasedSampler, + TraceIdRatioBasedSampler, +} from "@opentelemetry/sdk-trace-base"; +import { WebTracerProvider } from "@opentelemetry/sdk-trace-web"; +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, +} from "@opentelemetry/semantic-conventions"; +import { OTEL_TRACE_SAMPLE_RATIO } from "@posthog/shared/constants"; +import type { AnyRouter } from "@tanstack/react-router"; +import type { ProfilerOnRenderCallback } from "react"; + +const SLOW_COMMIT_THRESHOLD_MS = 16; + +let tracerProvider: WebTracerProvider | null = null; +let tracer: Tracer | null = null; + +export function initOtelTracing(): Tracer | null { + const apiKey = import.meta.env.VITE_POSTHOG_API_KEY; + const apiHost = + import.meta.env.VITE_POSTHOG_API_HOST || "https://internal-c.posthog.com"; + + if (!apiKey) { + return null; + } + + const url = `${apiHost}/i/v1/traces`; + try { + new URL(url); + } catch { + return null; + } + + const exporter = new OTLPTraceExporter({ + url, + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + tracerProvider = new WebTracerProvider({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: "posthog-code-desktop", + [ATTR_SERVICE_VERSION]: + typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "unknown", + "service.namespace": "renderer", + }), + sampler: new ParentBasedSampler({ + root: new TraceIdRatioBasedSampler(OTEL_TRACE_SAMPLE_RATIO), + }), + spanProcessors: [ + new BatchSpanProcessor(exporter, { scheduledDelayMillis: 2000 }), + ], + }); + + tracerProvider.register(); + + tracer = tracerProvider.getTracer("renderer"); + + observeLongTasks(); + flushOnUnload(); + + return tracer; +} + +export const onAppRender: ProfilerOnRenderCallback = ( + id, + phase, + actualDuration, + baseDuration, +) => { + if (!tracer || actualDuration < SLOW_COMMIT_THRESHOLD_MS) { + return; + } + + const span = tracer.startSpan(`react.commit ${id}`); + span.setAttribute("react.phase", phase); + span.setAttribute("react.actual_duration_ms", actualDuration); + span.setAttribute("react.base_duration_ms", baseDuration); + span.end(); +}; + +export function traceNavigations(router: AnyRouter): void { + let navigationSpan: Span | null = null; + + router.subscribe("onBeforeNavigate", (event) => { + if (!tracer) return; + navigationSpan?.end(); + navigationSpan = tracer.startSpan("route.navigate"); + navigationSpan.setAttribute( + "route.from", + event.fromLocation?.pathname ?? "", + ); + navigationSpan.setAttribute("route.to", event.toLocation.pathname); + }); + + router.subscribe("onResolved", () => { + navigationSpan?.end(); + navigationSpan = null; + }); +} + +function observeLongTasks(): void { + if (typeof PerformanceObserver === "undefined") return; + + try { + const observer = new PerformanceObserver((list) => { + if (!tracer) return; + for (const entry of list.getEntries()) { + const span = tracer.startSpan("browser.longtask"); + span.setAttribute("duration_ms", entry.duration); + span.setAttribute("longtask.name", entry.name); + span.end(); + } + }); + observer.observe({ entryTypes: ["longtask"] }); + } catch { + // longtask is not observable in every environment; ignore. + } +} + +function flushOnUnload(): void { + if (typeof window === "undefined") return; + window.addEventListener("pagehide", () => { + void tracerProvider?.forceFlush(); + }); +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b0a3336e2..0e2e77c08 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -9,3 +9,5 @@ export { export const SELF_DRIVING_SETUP_TASK_FLAG = "posthog-code-self-driving-setup-task"; export const BRANCH_PREFIX = "posthog-code/"; + +export const OTEL_TRACE_SAMPLE_RATIO = 0.02; diff --git a/packages/workspace-client/package.json b/packages/workspace-client/package.json index fc9bec9be..8db557037 100644 --- a/packages/workspace-client/package.json +++ b/packages/workspace-client/package.json @@ -15,6 +15,7 @@ "clean": "node ../../scripts/rimraf.mjs .turbo" }, "dependencies": { + "@opentelemetry/api": "^1.9.0", "@trpc/client": "catalog:", "superjson": "catalog:" }, diff --git a/packages/workspace-client/src/client.ts b/packages/workspace-client/src/client.ts index 79b518f93..64bdb95de 100644 --- a/packages/workspace-client/src/client.ts +++ b/packages/workspace-client/src/client.ts @@ -1,3 +1,4 @@ +import { context, propagation } from "@opentelemetry/api"; import type { AppRouter } from "@posthog/workspace-server/trpc"; import { createTRPCClient, @@ -9,6 +10,14 @@ import superjson from "superjson"; const SECRET_HEADER = "x-workspace-secret"; +function tracePropagationHeaders( + base: Record, +): Record { + const carrier: Record = { ...base }; + propagation.inject(context.active(), carrier); + return carrier; +} + export interface WorkspaceConnection { url: string; secret: string; @@ -32,7 +41,7 @@ export function createWorkspaceClient(connection: WorkspaceConnection) { false: httpBatchLink({ url, transformer: superjson, - headers: () => headers, + headers: () => tracePropagationHeaders(headers), }), }), ], diff --git a/packages/workspace-server/package.json b/packages/workspace-server/package.json index bc599818b..356bad0ee 100644 --- a/packages/workspace-server/package.json +++ b/packages/workspace-server/package.json @@ -23,6 +23,12 @@ "@anthropic-ai/claude-agent-sdk": "0.3.156", "@hono/node-server": "catalog:", "@hono/trpc-server": "catalog:", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.208.0", + "@opentelemetry/resources": "^2.5.0", + "@opentelemetry/sdk-trace-base": "^2.5.0", + "@opentelemetry/sdk-trace-node": "^2.5.0", + "@opentelemetry/semantic-conventions": "^1.39.0", "@parcel/watcher": "catalog:", "@posthog/agent": "workspace:*", "@posthog/di": "workspace:*", diff --git a/packages/workspace-server/src/app.ts b/packages/workspace-server/src/app.ts index d0c08963b..149825495 100644 --- a/packages/workspace-server/src/app.ts +++ b/packages/workspace-server/src/app.ts @@ -1,5 +1,6 @@ import { timingSafeEqual } from "node:crypto"; import { trpcServer } from "@hono/trpc-server"; +import { context, propagation, type TextMapGetter } from "@opentelemetry/api"; import { Hono } from "hono"; import { createMiddleware } from "hono/factory"; import { HTTPException } from "hono/http-exception"; @@ -7,6 +8,11 @@ import { appRouter } from "./trpc"; const SECRET_HEADER = "x-workspace-secret"; +const headersGetter: TextMapGetter = { + get: (carrier, key) => carrier.get(key) ?? undefined, + keys: (carrier) => [...carrier.keys()], +}; + export interface CreateAppOptions { sharedSecret: string; } @@ -33,7 +39,17 @@ export function createApp(options: CreateAppOptions): Hono { await next(); }); + const extractTraceContext = createMiddleware(async (c, next) => { + const active = propagation.extract( + context.active(), + c.req.raw.headers, + headersGetter, + ); + await context.with(active, next); + }); + app.use("/trpc/*", requireSecret); + app.use("/trpc/*", extractTraceContext); app.use("/trpc/*", trpcServer({ router: appRouter })); return app; diff --git a/packages/workspace-server/src/node-tracing.ts b/packages/workspace-server/src/node-tracing.ts new file mode 100644 index 000000000..3981586e0 --- /dev/null +++ b/packages/workspace-server/src/node-tracing.ts @@ -0,0 +1,113 @@ +import os from "node:os"; +import { + type Attributes, + SpanStatusCode, + type Tracer, +} from "@opentelemetry/api"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { resourceFromAttributes } from "@opentelemetry/resources"; +import { + BatchSpanProcessor, + ParentBasedSampler, + TraceIdRatioBasedSampler, +} from "@opentelemetry/sdk-trace-base"; +import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, +} from "@opentelemetry/semantic-conventions"; +import { OTEL_TRACE_SAMPLE_RATIO } from "@posthog/shared/constants"; +import type { TRPCError } from "@trpc/server"; + +export interface NodeTracingOptions { + serviceName: string; + serviceVersion: string; + attributes?: Attributes; +} + +export interface NodeTracing { + tracer: Tracer; + shutdown: () => Promise; +} + +export function initNodeTracing( + options: NodeTracingOptions, +): NodeTracing | null { + const apiKey = process.env.VITE_POSTHOG_API_KEY; + const apiHost = process.env.VITE_POSTHOG_API_HOST; + + if (!apiKey || !apiHost) { + return null; + } + + const url = `${apiHost}/i/v1/traces`; + try { + new URL(url); + } catch { + return null; + } + + const provider = new NodeTracerProvider({ + resource: resourceFromAttributes({ + [ATTR_SERVICE_NAME]: options.serviceName, + [ATTR_SERVICE_VERSION]: options.serviceVersion, + "os.type": process.platform, + "os.version": os.release(), + ...options.attributes, + }), + sampler: new ParentBasedSampler({ + root: new TraceIdRatioBasedSampler(OTEL_TRACE_SAMPLE_RATIO), + }), + spanProcessors: [ + new BatchSpanProcessor( + new OTLPTraceExporter({ + url, + headers: { Authorization: `Bearer ${apiKey}` }, + }), + { scheduledDelayMillis: 2000 }, + ), + ], + }); + + provider.register(); + + return { + tracer: provider.getTracer(options.serviceName), + shutdown: async () => { + await provider.forceFlush(); + await provider.shutdown(); + }, + }; +} + +export function traceTrpcCall( + tracer: Tracer | null, + path: string, + type: string, + next: () => Promise, +): Promise { + if (!tracer || type === "subscription") { + return next(); + } + + return tracer.startActiveSpan(`trpc.${type} ${path}`, async (span) => { + span.setAttribute("rpc.system", "trpc"); + span.setAttribute("rpc.method", path); + span.setAttribute("trpc.type", type); + try { + const result = await next(); + const error = (result as { error?: TRPCError }).error; + if (error) { + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR }); + } + return result; + } catch (error) { + span.recordException(error as Error); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw error; + } finally { + span.end(); + } + }); +} diff --git a/packages/workspace-server/src/otel-trace.ts b/packages/workspace-server/src/otel-trace.ts new file mode 100644 index 000000000..08e5dff27 --- /dev/null +++ b/packages/workspace-server/src/otel-trace.ts @@ -0,0 +1,25 @@ +import type { Tracer } from "@opentelemetry/api"; +import { initNodeTracing, type NodeTracing } from "./node-tracing"; + +let current: NodeTracing | null = null; + +export function initOtelTracing(): Tracer | null { + current = initNodeTracing({ + serviceName: "posthog-code-workspace-server", + serviceVersion: process.env.POSTHOG_CODE_VERSION ?? "unknown", + attributes: { + "process.runtime.name": "node", + "process.runtime.version": process.versions.node, + }, + }); + return current?.tracer ?? null; +} + +export function getWorkspaceServerTracer(): Tracer | null { + return current?.tracer ?? null; +} + +export async function shutdownOtelTracing(): Promise { + await current?.shutdown(); + current = null; +} diff --git a/packages/workspace-server/src/serve.ts b/packages/workspace-server/src/serve.ts index de8badfc1..a7454b4d2 100644 --- a/packages/workspace-server/src/serve.ts +++ b/packages/workspace-server/src/serve.ts @@ -1,8 +1,11 @@ import "reflect-metadata"; import { serve } from "@hono/node-server"; import { createApp } from "./app"; +import { initOtelTracing, shutdownOtelTracing } from "./otel-trace"; const SHUTDOWN_GRACE_MS = 3_000; + +initOtelTracing(); const WATCHDOG_INTERVAL_MS = 2_000; function isParentAlive(parentPid: number): boolean { @@ -33,6 +36,7 @@ const shutdown = (reason: string) => { if (shuttingDown) return; shuttingDown = true; process.stdout.write(`[workspace-server] shutdown (${reason})\n`); + void shutdownOtelTracing(); if (!server) process.exit(0); server.close(); setTimeout(() => process.exit(0), SHUTDOWN_GRACE_MS).unref(); diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index dc8f8d66c..9d04a7777 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -60,6 +60,7 @@ import { import { inject, injectable, preDestroy } from "inversify"; import { WORKSPACE_REPOSITORY } from "../../db/identifiers"; import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; +import { withSpan } from "../../with-span"; import { POSTHOG_PLUGIN_SERVICE } from "../posthog-plugin/identifiers"; import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; @@ -683,49 +684,64 @@ When creating pull requests, add the following footer at the end of the PR descr systemPromptOverride, ); - const acpConnection = await agent.run(taskId, taskRunId, { - adapter, - gatewayUrl: proxyUrl, - codexBinaryPath: - adapter === "codex" ? this.getCodexBinaryPath() : undefined, - model, - instructions: adapter === "codex" ? systemPrompt.append : undefined, - additionalDirectories: - adapter === "codex" ? additionalDirectories : undefined, - onStructuredOutput: jsonSchema - ? async (output) => { - const posthogAPI = agent.getPosthogAPI(); - if (posthogAPI) { - await posthogAPI.updateTaskRun(taskId, taskRunId, { output }); - } - } - : undefined, - processCallbacks: { - onProcessSpawned: (info) => { - this.processTracking.register( - info.pid, - "agent", - `agent:${taskRunId}`, - { - taskRunId, - taskId, - command: info.command, - }, - taskId, - ); - }, - onProcessExited: (pid) => { - this.processTracking.unregister(pid, "agent-exited"); - }, - onMcpServersReady: (serverNames) => { - this.mcpAppsService.handleDiscovery(serverNames).catch((err) => { - this.log.warn("MCP Apps discovery failed", { - error: err instanceof Error ? err.message : String(err), - }); - }); - }, + const acpConnection = await withSpan( + "agent.run.start", + { + "task.id": taskId, + "task.run_id": taskRunId, + "agent.adapter": adapter, + "agent.model": model ?? "", + "agent.preview": isPreview, }, - }); + () => + agent.run(taskId, taskRunId, { + adapter, + gatewayUrl: proxyUrl, + codexBinaryPath: + adapter === "codex" ? this.getCodexBinaryPath() : undefined, + model, + instructions: adapter === "codex" ? systemPrompt.append : undefined, + additionalDirectories: + adapter === "codex" ? additionalDirectories : undefined, + onStructuredOutput: jsonSchema + ? async (output) => { + const posthogAPI = agent.getPosthogAPI(); + if (posthogAPI) { + await posthogAPI.updateTaskRun(taskId, taskRunId, { + output, + }); + } + } + : undefined, + processCallbacks: { + onProcessSpawned: (info) => { + this.processTracking.register( + info.pid, + "agent", + `agent:${taskRunId}`, + { + taskRunId, + taskId, + command: info.command, + }, + taskId, + ); + }, + onProcessExited: (pid) => { + this.processTracking.unregister(pid, "agent-exited"); + }, + onMcpServersReady: (serverNames) => { + this.mcpAppsService + .handleDiscovery(serverNames) + .catch((err) => { + this.log.warn("MCP Apps discovery failed", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }, + }, + }), + ); const { clientStreams } = acpConnection; const connection = this.createClientConnection( diff --git a/packages/workspace-server/src/services/git/service.ts b/packages/workspace-server/src/services/git/service.ts index 11ba8e535..ab08449aa 100644 --- a/packages/workspace-server/src/services/git/service.ts +++ b/packages/workspace-server/src/services/git/service.ts @@ -46,6 +46,7 @@ import { StashPushSaga } from "@posthog/git/sagas/stash"; import { parseGithubUrl } from "@posthog/git/utils"; import { TypedEventEmitter } from "@posthog/shared"; import { injectable } from "inversify"; +import { withSpan } from "../../with-span"; import type { SidebarPrState } from "../workspace/schemas"; import type { ChangedFile, @@ -685,11 +686,10 @@ export class GitService extends TypedEventEmitter { remote = "origin", signal?: AbortSignal, ): Promise { - const pullResult = await this.pull( - directoryPath, - remote, - undefined, - signal, + const pullResult = await withSpan( + "git.sync.pull", + { "git.remote": remote }, + () => this.pull(directoryPath, remote, undefined, signal), ); if (!pullResult.success) { return { @@ -699,15 +699,15 @@ export class GitService extends TypedEventEmitter { }; } - const pushResult = await this.push( - directoryPath, - remote, - undefined, - false, - signal, + const pushResult = await withSpan( + "git.sync.push", + { "git.remote": remote }, + () => this.push(directoryPath, remote, undefined, false, signal), ); - const state = await this.getStateSnapshot(directoryPath); + const state = await withSpan("git.sync.snapshot", {}, () => + this.getStateSnapshot(directoryPath), + ); return { success: pushResult.success, diff --git a/packages/workspace-server/src/trpc.ts b/packages/workspace-server/src/trpc.ts index 87728e93d..acc8ba840 100644 --- a/packages/workspace-server/src/trpc.ts +++ b/packages/workspace-server/src/trpc.ts @@ -3,6 +3,8 @@ import superjson from "superjson"; import { z } from "zod"; import { container } from "./di/container"; import { TOKENS } from "./di/tokens"; +import { traceTrpcCall } from "./node-tracing"; +import { getWorkspaceServerTracer } from "./otel-trace"; import { connectivityStatusOutput } from "./services/connectivity/schemas"; import type { ConnectivityService } from "./services/connectivity/service"; import { @@ -148,6 +150,12 @@ import type { WatcherService } from "./services/watcher/service"; const t = initTRPC.create({ transformer: superjson }); +const tracingMiddleware = t.middleware(({ path, type, next }) => + traceTrpcCall(getWorkspaceServerTracer(), path, type, next), +); + +const tracedProcedure = t.procedure.use(tracingMiddleware); + const focusService = () => container.get(TOKENS.FocusService); const focusSyncService = () => container.get(TOKENS.FocusSyncService); @@ -182,106 +190,106 @@ export { export const appRouter = t.router({ focus: t.router({ - getSession: t.procedure + getSession: tracedProcedure .input(mainRepoPathInput) .output(focusSessionSchema.nullable()) .query(({ input }) => focusService().getSession(input.mainRepoPath)), - saveSession: t.procedure + saveSession: tracedProcedure .input(focusSessionSchema) .mutation(({ input }) => focusService().saveSession(input)), - deleteSession: t.procedure + deleteSession: tracedProcedure .input(mainRepoPathInput) .mutation(({ input }) => focusService().deleteSession(input.mainRepoPath), ), - isFocusActive: t.procedure + isFocusActive: tracedProcedure .input(mainRepoPathInput) .output(z.boolean()) .query(({ input }) => focusService().isFocusActive(input.mainRepoPath)), - isDirty: t.procedure + isDirty: tracedProcedure .input(repoPathInput) .output(z.boolean()) .query(({ input }) => focusService().isDirty(input.repoPath)), - getCommitSha: t.procedure + getCommitSha: tracedProcedure .input(repoPathInput) .output(z.string()) .query(({ input }) => focusService().getCommitSha(input.repoPath)), - findWorktreeByBranch: t.procedure + findWorktreeByBranch: tracedProcedure .input(findWorktreeInput) .output(z.string().nullable()) .query(({ input }) => focusService().findWorktreeByBranch(input.mainRepoPath, input.branch), ), - stash: t.procedure + stash: tracedProcedure .input(stashInput) .output(stashResultSchema) .mutation(({ input }) => focusService().stash(input.repoPath, input.message), ), - stashPop: t.procedure + stashPop: tracedProcedure .input(repoPathInput) .output(focusResultSchema) .mutation(({ input }) => focusService().stashPop(input.repoPath)), - stashApply: t.procedure + stashApply: tracedProcedure .input(z.object({ repoPath: z.string(), stashRef: z.string() })) .output(focusResultSchema) .mutation(({ input }) => focusService().stashApply(input.repoPath, input.stashRef), ), - checkout: t.procedure + checkout: tracedProcedure .input(checkoutInput) .output(focusResultSchema) .mutation(({ input }) => focusService().checkout(input.repoPath, input.branch), ), - detachWorktree: t.procedure + detachWorktree: tracedProcedure .input(worktreeInput) .output(focusResultSchema) .mutation(({ input }) => focusService().detachWorktree(input.worktreePath), ), - reattachWorktree: t.procedure + reattachWorktree: tracedProcedure .input(reattachInput) .output(focusResultSchema) .mutation(({ input }) => focusService().reattachWorktree(input.worktreePath, input.branch), ), - cleanWorkingTree: t.procedure + cleanWorkingTree: tracedProcedure .input(repoPathInput) .mutation(({ input }) => focusService().cleanWorkingTree(input.repoPath)), - startSync: t.procedure + startSync: tracedProcedure .input(syncInput) .mutation(({ input }) => focusSyncService().startSync(input.mainRepoPath, input.worktreePath), ), - stopSync: t.procedure.mutation(() => focusSyncService().stopSync()), + stopSync: tracedProcedure.mutation(() => focusSyncService().stopSync()), - startWatchingMainRepo: t.procedure + startWatchingMainRepo: tracedProcedure .input(mainRepoPathInput) .mutation(({ input }) => focusService().startWatchingMainRepo(input.mainRepoPath), ), - stopWatchingMainRepo: t.procedure.mutation(() => + stopWatchingMainRepo: tracedProcedure.mutation(() => focusService().stopWatchingMainRepo(), ), - onBranchRenamed: t.procedure.subscription(async function* (opts) { + onBranchRenamed: tracedProcedure.subscription(async function* (opts) { for await (const event of focusService().branchRenamedEvents( opts.signal, )) { @@ -289,64 +297,66 @@ export const appRouter = t.router({ } }), - onForeignBranchCheckout: t.procedure.subscription(async function* (opts) { - for await (const event of focusService().foreignBranchCheckoutEvents( - opts.signal, - )) { - yield event; - } - }), + onForeignBranchCheckout: tracedProcedure.subscription( + async function* (opts) { + for await (const event of focusService().foreignBranchCheckoutEvents( + opts.signal, + )) { + yield event; + } + }, + ), }), git: t.router({ - detectRepo: t.procedure + detectRepo: tracedProcedure .input(directoryPathInput) .output(detectRepoResultSchema) .query(({ input }) => gitService().detectRepo(input.directoryPath)), - validateRepo: t.procedure + validateRepo: tracedProcedure .input(directoryPathInput) .output(z.boolean()) .query(({ input }) => gitService().validateRepo(input.directoryPath)), - getRemoteUrl: t.procedure + getRemoteUrl: tracedProcedure .input(directoryPathInput) .output(stringNullableOutput) .query(({ input }) => gitService().getRemoteUrl(input.directoryPath)), - getCurrentBranch: t.procedure + getCurrentBranch: tracedProcedure .input(directoryPathInput) .output(stringNullableOutput) .query(({ input, signal }) => gitService().getCurrentBranch(input.directoryPath, signal), ), - getDefaultBranch: t.procedure + getDefaultBranch: tracedProcedure .input(directoryPathInput) .output(stringOutput) .query(({ input }) => gitService().getDefaultBranch(input.directoryPath)), - getAllBranches: t.procedure + getAllBranches: tracedProcedure .input(directoryPathInput) .output(stringArrayOutput) .query(({ input, signal }) => gitService().getAllBranches(input.directoryPath, signal), ), - getChangedFilesHead: t.procedure + getChangedFilesHead: tracedProcedure .input(directoryPathInput) .output(changedFilesOutput) .query(({ input, signal }) => gitService().getChangedFilesHead(input.directoryPath, signal), ), - getFileAtHead: t.procedure + getFileAtHead: tracedProcedure .input(filePathInput) .output(stringNullableOutput) .query(({ input, signal }) => gitService().getFileAtHead(input.directoryPath, input.filePath, signal), ), - getDiffHead: t.procedure + getDiffHead: tracedProcedure .input(diffInput) .output(stringOutput) .query(({ input, signal }) => @@ -357,7 +367,7 @@ export const appRouter = t.router({ ), ), - getDiffCached: t.procedure + getDiffCached: tracedProcedure .input(diffInput) .output(stringOutput) .query(({ input, signal }) => @@ -368,7 +378,7 @@ export const appRouter = t.router({ ), ), - getDiffUnstaged: t.procedure + getDiffUnstaged: tracedProcedure .input(diffInput) .output(stringOutput) .query(({ input, signal }) => @@ -379,60 +389,60 @@ export const appRouter = t.router({ ), ), - getLatestCommit: t.procedure + getLatestCommit: tracedProcedure .input(directoryPathInput) .output(gitCommitInfoNullableOutput) .query(({ input, signal }) => gitService().getLatestCommit(input.directoryPath, signal), ), - getGitRepoInfo: t.procedure + getGitRepoInfo: tracedProcedure .input(directoryPathInput) .output(gitRepoInfoNullableOutput) .query(({ input }) => gitService().getGitRepoInfo(input.directoryPath)), - getGitBusyState: t.procedure + getGitBusyState: tracedProcedure .input(gitBusyStateInput) .output(gitBusyStateSchema) .query(({ input, signal }) => gitService().getGitBusyState(input.directoryPath, signal), ), - getGitSyncStatus: t.procedure + getGitSyncStatus: tracedProcedure .input(getGitSyncStatusInput) .output(gitSyncStatusSchema) .query(({ input }) => gitService().getGitSyncStatus(input.directoryPath, input.forceRefresh), ), - createBranch: t.procedure + createBranch: tracedProcedure .input(createBranchInput) .mutation(({ input }) => gitService().createBranch(input.directoryPath, input.branchName), ), - checkoutBranch: t.procedure + checkoutBranch: tracedProcedure .input(checkoutBranchInput) .output(checkoutBranchOutput) .mutation(({ input }) => gitService().checkoutBranch(input.directoryPath, input.branchName), ), - stageFiles: t.procedure + stageFiles: tracedProcedure .input(stageFilesInput) .output(gitStateSnapshotSchema) .mutation(({ input }) => gitService().stageFiles(input.directoryPath, input.paths), ), - unstageFiles: t.procedure + unstageFiles: tracedProcedure .input(stageFilesInput) .output(gitStateSnapshotSchema) .mutation(({ input }) => gitService().unstageFiles(input.directoryPath, input.paths), ), - discardFileChanges: t.procedure + discardFileChanges: tracedProcedure .input(discardFileChangesInput) .output(discardFileChangesOutput) .mutation(({ input }) => @@ -443,7 +453,7 @@ export const appRouter = t.router({ ), ), - push: t.procedure + push: tracedProcedure .input(pushInput) .output(pushOutput) .mutation(({ input, signal }) => @@ -457,7 +467,7 @@ export const appRouter = t.router({ ), ), - commit: t.procedure + commit: tracedProcedure .input(commitInput) .output(commitOutput) .mutation(({ input }) => @@ -469,7 +479,7 @@ export const appRouter = t.router({ }), ), - pull: t.procedure + pull: tracedProcedure .input(pullInput) .output(pullOutput) .mutation(({ input, signal }) => @@ -481,7 +491,7 @@ export const appRouter = t.router({ ), ), - publish: t.procedure + publish: tracedProcedure .input(publishInput) .output(publishOutput) .mutation(({ input, signal }) => @@ -493,61 +503,61 @@ export const appRouter = t.router({ ), ), - sync: t.procedure + sync: tracedProcedure .input(gitSyncInput) .output(gitSyncOutput) .mutation(({ input, signal }) => gitService().sync(input.directoryPath, input.remote, signal), ), - getGhStatus: t.procedure + getGhStatus: tracedProcedure .output(ghStatusOutput) .query(() => gitService().getGhStatus()), - getGhAuthToken: t.procedure + getGhAuthToken: tracedProcedure .output(ghAuthTokenOutput) .query(() => gitService().getGhAuthToken()), - getPrStatus: t.procedure + getPrStatus: tracedProcedure .input(directoryPathInput) .output(prStatusOutput) .query(({ input }) => gitService().getPrStatus(input.directoryPath)), - getPrUrlForBranch: t.procedure + getPrUrlForBranch: tracedProcedure .input(getPrUrlForBranchInput) .output(getPrUrlForBranchOutput) .query(({ input }) => gitService().getPrUrlForBranch(input.directoryPath, input.branchName), ), - openPr: t.procedure + openPr: tracedProcedure .input(openPrInput) .output(openPrOutput) .mutation(({ input }) => gitService().openPr(input.directoryPath)), - getPrDetailsByUrl: t.procedure + getPrDetailsByUrl: tracedProcedure .input(getPrDetailsByUrlInput) .output(getPrDetailsByUrlOutput.nullable()) .query(({ input }) => gitService().getPrDetailsByUrl(input.prUrl)), - getPrChangedFiles: t.procedure + getPrChangedFiles: tracedProcedure .input(getPrChangedFilesInput) .output(changedFilesOutput) .query(({ input }) => gitService().getPrChangedFiles(input.prUrl)), - getPrDiffStatsBatch: t.procedure + getPrDiffStatsBatch: tracedProcedure .input(getPrDiffStatsBatchInput) .output(getPrDiffStatsBatchOutput) .query(({ input }) => gitService().getPrDiffStatsBatch(input.prUrls)), - getBranchChangedFiles: t.procedure + getBranchChangedFiles: tracedProcedure .input(getBranchChangedFilesInput) .output(changedFilesOutput) .query(({ input }) => gitService().getBranchChangedFiles(input.repo, input.branch), ), - getLocalBranchChangedFiles: t.procedure + getLocalBranchChangedFiles: tracedProcedure .input(getLocalBranchChangedFilesInput) .output(changedFilesOutput) .query(({ input }) => @@ -557,38 +567,38 @@ export const appRouter = t.router({ ), ), - updatePrByUrl: t.procedure + updatePrByUrl: tracedProcedure .input(updatePrByUrlInput) .output(updatePrByUrlOutput) .mutation(({ input }) => gitService().updatePrByUrl(input.prUrl, input.action), ), - getPrReviewComments: t.procedure + getPrReviewComments: tracedProcedure .input(getPrReviewCommentsInput) .output(getPrReviewCommentsOutput) .query(({ input }) => gitService().getPrReviewComments(input.prUrl)), - resolveReviewThread: t.procedure + resolveReviewThread: tracedProcedure .input(resolveReviewThreadInput) .output(resolveReviewThreadOutput) .mutation(({ input }) => gitService().resolveReviewThread(input.threadNodeId, input.resolved), ), - replyToPrComment: t.procedure + replyToPrComment: tracedProcedure .input(replyToPrCommentInput) .output(replyToPrCommentOutput) .mutation(({ input }) => gitService().replyToPrComment(input.prUrl, input.commentId, input.body), ), - getPrTemplate: t.procedure + getPrTemplate: tracedProcedure .input(getPrTemplateInput) .output(getPrTemplateOutput) .query(({ input }) => gitService().getPrTemplate(input.directoryPath)), - getCommitConventions: t.procedure + getCommitConventions: tracedProcedure .input(getCommitConventionsInput) .output(getCommitConventionsOutput) .query(({ input }) => @@ -598,7 +608,7 @@ export const appRouter = t.router({ ), ), - searchGithubRefs: t.procedure + searchGithubRefs: tracedProcedure .input(searchGithubRefsInput) .output(searchGithubRefsOutput) .query(({ input }) => @@ -610,14 +620,14 @@ export const appRouter = t.router({ ), ), - getGithubIssue: t.procedure + getGithubIssue: tracedProcedure .input(getGithubIssueInput) .output(getGithubIssueOutput) .query(({ input }) => gitService().getGithubIssue(input.owner, input.repo, input.number), ), - getGithubPullRequest: t.procedure + getGithubPullRequest: tracedProcedure .input(getGithubPullRequestInput) .output(getGithubPullRequestOutput) .query(({ input }) => @@ -628,14 +638,14 @@ export const appRouter = t.router({ ), ), - readHandoffLocalGitState: t.procedure + readHandoffLocalGitState: tracedProcedure .input(readHandoffLocalGitStateInput) .output(readHandoffLocalGitStateOutput) .query(({ input }) => gitService().readHandoffLocalGitState(input.directoryPath), ), - cleanupAfterCloudHandoff: t.procedure + cleanupAfterCloudHandoff: tracedProcedure .input(cleanupAfterCloudHandoffInput) .output(cleanupAfterCloudHandoffOutput) .mutation(({ input }) => @@ -645,21 +655,21 @@ export const appRouter = t.router({ ), ), - getDiffStats: t.procedure + getDiffStats: tracedProcedure .input(diffStatsInput) .output(diffStatsSchema) .query(({ input }) => gitService().getDiffStats(input.directoryPath)), - getGitStatus: t.procedure + getGitStatus: tracedProcedure .output(gitStatusOutput) .query(() => gitService().getGitStatus()), - getHeadSha: t.procedure + getHeadSha: tracedProcedure .input(directoryPathInput) .output(getHeadShaOutput) .query(({ input }) => gitService().getHeadSha(input.directoryPath)), - getDiffAgainstRemote: t.procedure + getDiffAgainstRemote: tracedProcedure .input(getDiffAgainstRemoteInput) .output(stringOutput) .query(({ input }) => @@ -669,7 +679,7 @@ export const appRouter = t.router({ ), ), - getCommitsBetweenBranches: t.procedure + getCommitsBetweenBranches: tracedProcedure .input(getCommitsBetweenBranchesInput) .output(getCommitsBetweenBranchesOutput) .query(({ input }) => @@ -681,13 +691,13 @@ export const appRouter = t.router({ ), ), - resetSoft: t.procedure + resetSoft: tracedProcedure .input(resetSoftInput) .mutation(({ input }) => gitService().resetSoft(input.directoryPath, input.sha), ), - createPrViaGh: t.procedure + createPrViaGh: tracedProcedure .input(createPrViaGhInput) .output(createPrViaGhOutput) .mutation(({ input }) => @@ -700,7 +710,7 @@ export const appRouter = t.router({ ), ), - cloneRepository: t.procedure + cloneRepository: tracedProcedure .input(cloneRepositoryInput) .output(cloneRepositoryOutput) .mutation(({ input }) => @@ -711,7 +721,7 @@ export const appRouter = t.router({ ), ), - onCloneProgress: t.procedure.subscription(async function* (opts) { + onCloneProgress: tracedProcedure.subscription(async function* (opts) { for await (const data of gitService().toIterable("cloneProgress", { signal: opts.signal, })) { @@ -720,39 +730,39 @@ export const appRouter = t.router({ }), }), diffStats: t.router({ - getDiffStats: t.procedure + getDiffStats: tracedProcedure .input(diffStatsInput) .output(diffStatsSchema) .query(({ input }) => gitService().getDiffStats(input.directoryPath)), }), fs: t.router({ - listDirectory: t.procedure + listDirectory: tracedProcedure .input(listDirectoryInput) .output(listDirectoryOutput) .query(({ input }) => fsService().listDirectory(input.dirPath)), - listRepoFiles: t.procedure + listRepoFiles: tracedProcedure .input(listRepoFilesInput) .output(listRepoFilesOutput) .query(({ input }) => fsService().listRepoFiles(input.repoPath, input.query, input.limit), ), - readRepoFile: t.procedure + readRepoFile: tracedProcedure .input(readRepoFileInput) .output(readRepoFileOutput) .query(({ input }) => fsService().readRepoFile(input.repoPath, input.filePath), ), - readRepoFiles: t.procedure + readRepoFiles: tracedProcedure .input(readRepoFilesInput) .output(readRepoFilesOutput) .query(({ input }) => fsService().readRepoFiles(input.repoPath, input.filePaths), ), - readRepoFileBounded: t.procedure + readRepoFileBounded: tracedProcedure .input(readRepoFileBoundedInput) .output(boundedReadResult) .query(({ input }) => @@ -763,7 +773,7 @@ export const appRouter = t.router({ ), ), - readRepoFilesBounded: t.procedure + readRepoFilesBounded: tracedProcedure .input(readRepoFilesBoundedInput) .output(readRepoFilesBoundedOutput) .query(({ input }) => @@ -774,17 +784,17 @@ export const appRouter = t.router({ ), ), - readAbsoluteFile: t.procedure + readAbsoluteFile: tracedProcedure .input(readAbsoluteFileInput) .output(readRepoFileOutput) .query(({ input }) => fsService().readAbsoluteFile(input.filePath)), - readFileAsBase64: t.procedure + readFileAsBase64: tracedProcedure .input(readAbsoluteFileInput) .output(readRepoFileOutput) .query(({ input }) => fsService().readFileAsBase64(input.filePath)), - writeRepoFile: t.procedure + writeRepoFile: tracedProcedure .input(writeRepoFileInput) .mutation(({ input }) => fsService().writeRepoFile( @@ -795,65 +805,65 @@ export const appRouter = t.router({ ), }), watcher: t.router({ - resolveGitDirs: t.procedure + resolveGitDirs: tracedProcedure .input(resolveGitDirsInput) .output(resolveGitDirsOutput) .query(({ input }) => watcherService().resolveGitDirs(input.repoPath)), - watch: t.procedure + watch: tracedProcedure .input(watchInput) .subscription(({ input, signal }) => watcherService().watch(input.dirPath, { ignore: input.ignore }, signal), ), }), fileWatcher: t.router({ - watch: t.procedure + watch: tracedProcedure .input(watchRepoInput) .subscription(({ input, signal }) => watcherService().watchRepo(input.repoPath, signal), ), }), localLogs: t.router({ - read: t.procedure + read: tracedProcedure .input(readLocalLogsInput) .output(readLocalLogsOutput) .query(({ input }) => localLogsService().readLocalLogs(input.taskRunId)), - write: t.procedure + write: tracedProcedure .input(writeLocalLogsInput) .mutation(({ input }) => localLogsService().writeLocalLogs(input.taskRunId, input.content), ), - seed: t.procedure + seed: tracedProcedure .input(seedLocalLogsInput) .mutation(({ input }) => localLogsService().seedLocalLogs(input.taskRunId, input.content), ), - count: t.procedure + count: tracedProcedure .input(countLocalLogEntriesInput) .output(countLocalLogEntriesOutput) .query(({ input }) => localLogsService().countLocalLogEntries(input.taskRunId), ), - delete: t.procedure + delete: tracedProcedure .input(deleteLocalLogCacheInput) .mutation(({ input }) => localLogsService().deleteLocalLogCache(input.taskRunId), ), }), connectivity: t.router({ - getStatus: t.procedure + getStatus: tracedProcedure .output(connectivityStatusOutput) .query(() => connectivityService().getStatus()), - checkNow: t.procedure + checkNow: tracedProcedure .output(connectivityStatusOutput) .mutation(() => connectivityService().checkNow()), - onStatusChange: t.procedure.subscription(async function* (opts) { + onStatusChange: tracedProcedure.subscription(async function* (opts) { for await (const status of connectivityService().statusChangeEvents( opts.signal, )) { @@ -862,21 +872,21 @@ export const appRouter = t.router({ }), }), environment: t.router({ - list: t.procedure + list: tracedProcedure .input(listEnvironmentsInput) .output(environmentSchema.array()) .query(({ input }) => environmentService().listEnvironments(input.repoPath), ), - get: t.procedure + get: tracedProcedure .input(getEnvironmentInput) .output(environmentSchema.nullable()) .query(({ input }) => environmentService().getEnvironment(input.repoPath, input.id), ), - create: t.procedure + create: tracedProcedure .input(createEnvironmentInput) .output(environmentSchema) .mutation(({ input }) => { @@ -884,7 +894,7 @@ export const appRouter = t.router({ return environmentService().createEnvironment(rest, repoPath); }), - update: t.procedure + update: tracedProcedure .input(updateEnvironmentInput) .output(environmentSchema) .mutation(({ input }) => { @@ -892,7 +902,7 @@ export const appRouter = t.router({ return environmentService().updateEnvironment(rest, repoPath); }), - delete: t.procedure + delete: tracedProcedure .input(deleteEnvironmentInput) .mutation(({ input }) => environmentService().deleteEnvironment(input.repoPath, input.id), diff --git a/packages/workspace-server/src/with-span.test.ts b/packages/workspace-server/src/with-span.test.ts new file mode 100644 index 000000000..2d5c9a7d2 --- /dev/null +++ b/packages/workspace-server/src/with-span.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetTracer = vi.fn(); + +vi.mock("./otel-trace", () => ({ + getWorkspaceServerTracer: () => mockGetTracer(), +})); + +describe("withSpan", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([ + ["resolves the wrapped value", 42, 42], + ["resolves objects", { ok: true }, { ok: true }], + ])("passes through when no tracer (%s)", async (_case, value, expected) => { + mockGetTracer.mockReturnValue(null); + const { withSpan } = await import("./with-span"); + + await expect(withSpan("op", {}, async () => value)).resolves.toEqual( + expected, + ); + }); + + it("propagates errors when no tracer", async () => { + mockGetTracer.mockReturnValue(null); + const { withSpan } = await import("./with-span"); + + await expect( + withSpan("op", {}, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + }); + + it("records exceptions and ends the span on error", async () => { + const span = { + recordException: vi.fn(), + setStatus: vi.fn(), + end: vi.fn(), + }; + mockGetTracer.mockReturnValue({ + startActiveSpan: ( + _name: string, + _opts: unknown, + fn: (s: unknown) => unknown, + ) => fn(span), + }); + const { withSpan } = await import("./with-span"); + + await expect( + withSpan("op", { a: 1 }, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + expect(span.recordException).toHaveBeenCalled(); + expect(span.setStatus).toHaveBeenCalled(); + expect(span.end).toHaveBeenCalled(); + }); +}); diff --git a/packages/workspace-server/src/with-span.ts b/packages/workspace-server/src/with-span.ts new file mode 100644 index 000000000..4c73082e1 --- /dev/null +++ b/packages/workspace-server/src/with-span.ts @@ -0,0 +1,25 @@ +import { type Attributes, SpanStatusCode } from "@opentelemetry/api"; +import { getWorkspaceServerTracer } from "./otel-trace"; + +export async function withSpan( + name: string, + attributes: Attributes, + fn: () => Promise, +): Promise { + const tracer = getWorkspaceServerTracer(); + if (!tracer) { + return fn(); + } + + return tracer.startActiveSpan(name, { attributes }, async (span) => { + try { + return await fn(); + } catch (error) { + span.recordException(error as Error); + span.setStatus({ code: SpanStatusCode.ERROR }); + throw error; + } finally { + span.end(); + } + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cc77acba..bd05e99d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,18 +133,36 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.12.1 version: 1.27.1(zod@4.3.6) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 '@opentelemetry/api-logs': specifier: ^0.208.0 version: 0.208.0 + '@opentelemetry/context-zone': + specifier: ^2.5.0 + version: 2.7.1(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-logs-otlp-http': specifier: ^0.208.0 version: 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.208.0 + version: 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': specifier: ^2.5.0 version: 2.5.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': specifier: ^0.208.0 version: 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': + specifier: ^2.5.0 + version: 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': + specifier: ^2.5.0 + version: 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-web': + specifier: ^2.5.0 + version: 2.7.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': specifier: ^1.39.0 version: 1.39.0 @@ -1406,6 +1424,9 @@ importers: packages/workspace-client: dependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 '@trpc/client': specifier: 'catalog:' version: 11.12.0(@trpc/server@11.12.0(typescript@5.9.3))(typescript@5.9.3) @@ -1449,6 +1470,24 @@ importers: '@hono/trpc-server': specifier: 'catalog:' version: 0.3.4(@trpc/server@11.12.0(typescript@5.9.3))(hono@4.11.7) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.208.0 + version: 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': + specifier: ^2.5.0 + version: 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': + specifier: ^2.5.0 + version: 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': + specifier: ^2.5.0 + version: 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': + specifier: ^1.39.0 + version: 1.39.0 '@parcel/watcher': specifier: 'catalog:' version: 2.5.6 @@ -4089,6 +4128,23 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/context-async-hooks@2.7.1': + resolution: {integrity: sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/context-zone-peer-dep@2.7.1': + resolution: {integrity: sha512-QPLvl82Ds+W9Tjz0s4b8UDUK9YkCb3pvaur4JQdgHe+eph6Ii20NbiC+wsdnBtG17DTPhmZcFvWMcQXZFBgeVw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + zone.js: ^0.10.2 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0 + + '@opentelemetry/context-zone@2.7.1': + resolution: {integrity: sha512-B42kO3zIMVbJ+wj5nlSkDvLF8cJY+7wDKLomHp10GL00nvUnhY67UQ/soZQgKR4dvPf8zTKbcONDsOiJLyRuXw==} + engines: {node: ^18.19.0 || >=20.6.0} + '@opentelemetry/core@2.2.0': resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4101,12 +4157,24 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.7.1': + resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/exporter-logs-otlp-http@0.208.0': resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-http@0.208.0': + resolution: {integrity: sha512-jbzDw1q+BkwKFq9yxhjAJ9rjKldbt5AgIy1gmEIJjEV/WRxQ3B6HcLVkwbjJ3RcMif86BDNKR846KJ0tY0aOJA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.208.0': resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4131,6 +4199,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/resources@2.7.1': + resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-logs@0.208.0': resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -4149,6 +4223,24 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.7.1': + resolution: {integrity: sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.7.1': + resolution: {integrity: sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace-web@2.7.1': + resolution: {integrity: sha512-K806OouCSOjMd8Nr7+ZCq3QT22tdAzzS/7h8vprfiKjkgFQ99/dvwU8d12WJANA6D5Qtme65hyBAqAu9CkQuxQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.39.0': resolution: {integrity: sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==} engines: {node: '>=14'} @@ -13443,6 +13535,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zone.js@0.16.2: + resolution: {integrity: sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -16758,6 +16853,22 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/context-zone-peer-dep@2.7.1(@opentelemetry/api@1.9.0)(zone.js@0.16.2)': + dependencies: + '@opentelemetry/api': 1.9.0 + zone.js: 0.16.2 + + '@opentelemetry/context-zone@2.7.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/context-zone-peer-dep': 2.7.1(@opentelemetry/api@1.9.0)(zone.js@0.16.2) + zone.js: 0.16.2 + transitivePeerDependencies: + - '@opentelemetry/api' + '@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -16768,6 +16879,11 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -16777,6 +16893,15 @@ snapshots: '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http@0.208.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -16806,6 +16931,12 @@ snapshots: '@opentelemetry/core': 2.5.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -16826,6 +16957,26 @@ snapshots: '@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.39.0 + '@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.39.0 + + '@opentelemetry/sdk-trace-node@2.7.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-trace-web@2.7.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions@1.39.0': {} '@oxc-parser/binding-android-arm-eabi@0.120.0': @@ -27360,6 +27511,8 @@ snapshots: zod@4.4.3: {} + zone.js@0.16.2: {} + zustand@4.5.7(@types/react@19.2.11)(immer@11.1.3)(react@19.1.0): dependencies: use-sync-external-store: 1.6.0(react@19.1.0) From 7ca2bcf46801ea4d320d2147bb39391d1295e82b Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sun, 14 Jun 2026 22:28:09 +0100 Subject: [PATCH 2/5] fix(otel): Address QA Swarm review on tracing PR - serve.ts: await the span flush (bounded by the grace timer) before process.exit so a clean workspace-server shutdown no longer drops buffered spans - shared: buildTracesEndpoint() strips trailing slash and asserts https (localhost http allowed for dev); OTEL_BATCH_DELAY_MS shares the batch delay across node + web so the magic number lives once - align renderer gating to require both key AND host (matches node + the log transport) so a key-only build can't half-instrument - init log line (enabled/disabled) on all three tracer inits - harden renderer flush: also flush on visibilitychange -> hidden - withSpan now takes the tracer explicitly, matching traceTrpcCall - tests: buildTracesEndpoint, renderer otel-trace gating + navigation + profiler threshold, workspace-client no-traceparent-when-disabled Generated-By: PostHog Code Task-Id: afd9489f-f2f8-4582-ab17-ace0d331ca0b --- apps/code/src/main/utils/otel-trace.ts | 6 + .../src/renderer/utils/otel-trace.test.ts | 130 ++++++++++++++++++ apps/code/src/renderer/utils/otel-trace.ts | 34 +++-- packages/shared/src/constants.test.ts | 42 ++++++ packages/shared/src/constants.ts | 18 +++ packages/workspace-client/package.json | 4 +- packages/workspace-client/src/client.test.ts | 17 +++ packages/workspace-client/src/client.ts | 2 +- packages/workspace-server/src/node-tracing.ts | 14 +- packages/workspace-server/src/otel-trace.ts | 3 + packages/workspace-server/src/serve.ts | 8 +- .../src/services/agent/agent.ts | 2 + .../src/services/git/service.ts | 6 +- .../workspace-server/src/with-span.test.ts | 30 +--- packages/workspace-server/src/with-span.ts | 9 +- pnpm-lock.yaml | 3 + 16 files changed, 276 insertions(+), 52 deletions(-) create mode 100644 apps/code/src/renderer/utils/otel-trace.test.ts create mode 100644 packages/shared/src/constants.test.ts create mode 100644 packages/workspace-client/src/client.test.ts diff --git a/apps/code/src/main/utils/otel-trace.ts b/apps/code/src/main/utils/otel-trace.ts index 32bad4e14..33b27e9c2 100644 --- a/apps/code/src/main/utils/otel-trace.ts +++ b/apps/code/src/main/utils/otel-trace.ts @@ -3,6 +3,7 @@ import { initNodeTracing, type NodeTracing, } from "@posthog/workspace-server/node-tracing"; +import log from "electron-log/main"; import { getAppVersion } from "./env"; let current: NodeTracing | null = null; @@ -17,6 +18,11 @@ export function initOtelTracing(): Tracer | null { "process.runtime.version": process.versions.electron, }, }); + log + .scope("otel") + .info( + `tracing ${current ? "enabled" : "disabled (missing/invalid VITE_POSTHOG_API_KEY/HOST)"}`, + ); return current?.tracer ?? null; } diff --git a/apps/code/src/renderer/utils/otel-trace.test.ts b/apps/code/src/renderer/utils/otel-trace.test.ts new file mode 100644 index 000000000..e6364ff39 --- /dev/null +++ b/apps/code/src/renderer/utils/otel-trace.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const span = { setAttribute: vi.fn(), end: vi.fn() }; +const mockStartSpan = vi.fn(() => span); +const mockRegister = vi.fn(); + +vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ + OTLPTraceExporter: class { + constructor(public config: unknown) {} + }, +})); + +vi.mock("@opentelemetry/sdk-trace-base", () => ({ + BatchSpanProcessor: class { + constructor( + public _e: unknown, + public _o: unknown, + ) {} + }, + ParentBasedSampler: class { + constructor(public _o: unknown) {} + }, + TraceIdRatioBasedSampler: class { + constructor(public _r: number) {} + }, +})); + +vi.mock("@opentelemetry/sdk-trace-web", () => ({ + WebTracerProvider: class { + constructor(public _o: unknown) {} + register() { + return mockRegister(); + } + getTracer() { + return { startSpan: mockStartSpan }; + } + forceFlush() { + return Promise.resolve(); + } + }, +})); + +vi.mock("@opentelemetry/resources", () => ({ + resourceFromAttributes: vi.fn((a: Record) => a), +})); + +vi.mock("@opentelemetry/semantic-conventions", () => ({ + ATTR_SERVICE_NAME: "service.name", + ATTR_SERVICE_VERSION: "service.version", +})); + +vi.mock("@renderer/utils/logger", () => ({ + logger: { scope: () => ({ info: vi.fn() }) }, +})); + +describe("renderer otel-trace", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + describe("initOtelTracing gating", () => { + it.each([ + ["key missing", "", "https://test.posthog.com"], + ["host missing", "phc_test", ""], + ["non-https host", "phc_test", "http://evil.example.com"], + ])("returns null when %s", async (_c, key, host) => { + vi.stubEnv("VITE_POSTHOG_API_KEY", key); + vi.stubEnv("VITE_POSTHOG_API_HOST", host); + const { initOtelTracing } = await import("@renderer/utils/otel-trace"); + expect(initOtelTracing()).toBeNull(); + expect(mockRegister).not.toHaveBeenCalled(); + }); + + it("registers and returns a tracer when configured", async () => { + vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test"); + vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com"); + const { initOtelTracing } = await import("@renderer/utils/otel-trace"); + expect(initOtelTracing()).not.toBeNull(); + expect(mockRegister).toHaveBeenCalled(); + }); + }); + + describe("traceNavigations", () => { + it("starts a span on navigate and ends it on resolve", async () => { + vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test"); + vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com"); + const { initOtelTracing, traceNavigations } = await import( + "@renderer/utils/otel-trace" + ); + initOtelTracing(); + + const handlers: Record void> = {}; + const router = { + subscribe: (event: string, cb: (e: unknown) => void) => { + handlers[event] = cb; + }, + } as never; + traceNavigations(router); + + handlers.onBeforeNavigate({ + fromLocation: { pathname: "/a" }, + toLocation: { pathname: "/b" }, + }); + expect(mockStartSpan).toHaveBeenCalledWith("route.navigate"); + expect(span.setAttribute).toHaveBeenCalledWith("route.to", "/b"); + + handlers.onResolved({}); + expect(span.end).toHaveBeenCalled(); + }); + }); + + describe("onAppRender", () => { + it("ignores commits under the threshold and spans slow ones", async () => { + vi.stubEnv("VITE_POSTHOG_API_KEY", "phc_test"); + vi.stubEnv("VITE_POSTHOG_API_HOST", "https://test.posthog.com"); + const { initOtelTracing, onAppRender } = await import( + "@renderer/utils/otel-trace" + ); + initOtelTracing(); + + onAppRender("app", "update", 4, 4, 0, 0); + expect(mockStartSpan).not.toHaveBeenCalled(); + + onAppRender("app", "update", 32, 30, 0, 0); + expect(mockStartSpan).toHaveBeenCalledWith("react.commit app"); + }); + }); +}); diff --git a/apps/code/src/renderer/utils/otel-trace.ts b/apps/code/src/renderer/utils/otel-trace.ts index e8f867769..cf4fdd145 100644 --- a/apps/code/src/renderer/utils/otel-trace.ts +++ b/apps/code/src/renderer/utils/otel-trace.ts @@ -11,7 +11,12 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, } from "@opentelemetry/semantic-conventions"; -import { OTEL_TRACE_SAMPLE_RATIO } from "@posthog/shared/constants"; +import { + buildTracesEndpoint, + OTEL_BATCH_DELAY_MS, + OTEL_TRACE_SAMPLE_RATIO, +} from "@posthog/shared/constants"; +import { logger } from "@renderer/utils/logger"; import type { AnyRouter } from "@tanstack/react-router"; import type { ProfilerOnRenderCallback } from "react"; @@ -22,17 +27,13 @@ let tracer: Tracer | null = null; export function initOtelTracing(): Tracer | null { const apiKey = import.meta.env.VITE_POSTHOG_API_KEY; - const apiHost = - import.meta.env.VITE_POSTHOG_API_HOST || "https://internal-c.posthog.com"; - - if (!apiKey) { - return null; - } + const apiHost = import.meta.env.VITE_POSTHOG_API_HOST; - const url = `${apiHost}/i/v1/traces`; - try { - new URL(url); - } catch { + const url = apiKey && apiHost ? buildTracesEndpoint(apiHost) : null; + if (!url) { + logger + .scope("otel") + .info("tracing disabled (missing/invalid VITE_POSTHOG_API_KEY/HOST)"); return null; } @@ -52,7 +53,9 @@ export function initOtelTracing(): Tracer | null { root: new TraceIdRatioBasedSampler(OTEL_TRACE_SAMPLE_RATIO), }), spanProcessors: [ - new BatchSpanProcessor(exporter, { scheduledDelayMillis: 2000 }), + new BatchSpanProcessor(exporter, { + scheduledDelayMillis: OTEL_BATCH_DELAY_MS, + }), ], }); @@ -63,6 +66,7 @@ export function initOtelTracing(): Tracer | null { observeLongTasks(); flushOnUnload(); + logger.scope("otel").info("tracing enabled"); return tracer; } @@ -124,7 +128,11 @@ function observeLongTasks(): void { function flushOnUnload(): void { if (typeof window === "undefined") return; - window.addEventListener("pagehide", () => { + const flush = () => { void tracerProvider?.forceFlush(); + }; + window.addEventListener("pagehide", flush); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flush(); }); } diff --git a/packages/shared/src/constants.test.ts b/packages/shared/src/constants.test.ts new file mode 100644 index 000000000..c317b4529 --- /dev/null +++ b/packages/shared/src/constants.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { buildTracesEndpoint } from "./constants"; + +describe("buildTracesEndpoint", () => { + it.each([ + [ + "https host", + "https://internal-c.posthog.com", + "https://internal-c.posthog.com/i/v1/traces", + ], + [ + "trailing slash stripped", + "https://eu.posthog.com/", + "https://eu.posthog.com/i/v1/traces", + ], + [ + "multiple trailing slashes stripped", + "https://us.posthog.com//", + "https://us.posthog.com/i/v1/traces", + ], + [ + "localhost http allowed", + "http://localhost:8010", + "http://localhost:8010/i/v1/traces", + ], + [ + "127.0.0.1 http allowed", + "http://127.0.0.1:8010", + "http://127.0.0.1:8010/i/v1/traces", + ], + ])("returns endpoint for %s", (_case, host, expected) => { + expect(buildTracesEndpoint(host)).toBe(expected); + }); + + it.each([ + ["non-localhost http rejected", "http://evil.example.com"], + ["unparseable host rejected", "not a url"], + ["empty host rejected", ""], + ])("returns null for %s", (_case, host) => { + expect(buildTracesEndpoint(host)).toBeNull(); + }); +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0e2e77c08..66a5dee90 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -11,3 +11,21 @@ export const SELF_DRIVING_SETUP_TASK_FLAG = export const BRANCH_PREFIX = "posthog-code/"; export const OTEL_TRACE_SAMPLE_RATIO = 0.02; + +export const OTEL_BATCH_DELAY_MS = 2000; + +export function buildTracesEndpoint(apiHost: string): string | null { + const url = `${apiHost.replace(/\/+$/, "")}/i/v1/traces`; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + const isLocalhost = + parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"; + if (parsed.protocol !== "https:" && !isLocalhost) { + return null; + } + return url; +} diff --git a/packages/workspace-client/package.json b/packages/workspace-client/package.json index 8db557037..b4149f8fa 100644 --- a/packages/workspace-client/package.json +++ b/packages/workspace-client/package.json @@ -12,6 +12,7 @@ }, "scripts": { "typecheck": "tsc --noEmit", + "test": "vitest run", "clean": "node ../../scripts/rimraf.mjs .turbo" }, "dependencies": { @@ -31,6 +32,7 @@ "@trpc/tanstack-react-query": "catalog:", "@types/react": "catalog:", "react": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^4.0.10" } } diff --git a/packages/workspace-client/src/client.test.ts b/packages/workspace-client/src/client.test.ts new file mode 100644 index 000000000..8ad599053 --- /dev/null +++ b/packages/workspace-client/src/client.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { tracePropagationHeaders } from "./client"; + +describe("tracePropagationHeaders", () => { + it("adds no traceparent when tracing is disabled (no propagator registered)", () => { + const base = { "x-workspace-secret": "abc" }; + const out = tracePropagationHeaders(base); + expect(out).toEqual(base); + expect(out).not.toHaveProperty("traceparent"); + }); + + it("does not mutate the input headers object", () => { + const base = { "x-workspace-secret": "abc" }; + tracePropagationHeaders(base); + expect(base).toEqual({ "x-workspace-secret": "abc" }); + }); +}); diff --git a/packages/workspace-client/src/client.ts b/packages/workspace-client/src/client.ts index 64bdb95de..daafe48a1 100644 --- a/packages/workspace-client/src/client.ts +++ b/packages/workspace-client/src/client.ts @@ -10,7 +10,7 @@ import superjson from "superjson"; const SECRET_HEADER = "x-workspace-secret"; -function tracePropagationHeaders( +export function tracePropagationHeaders( base: Record, ): Record { const carrier: Record = { ...base }; diff --git a/packages/workspace-server/src/node-tracing.ts b/packages/workspace-server/src/node-tracing.ts index 3981586e0..1ddc6a6b2 100644 --- a/packages/workspace-server/src/node-tracing.ts +++ b/packages/workspace-server/src/node-tracing.ts @@ -16,7 +16,11 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, } from "@opentelemetry/semantic-conventions"; -import { OTEL_TRACE_SAMPLE_RATIO } from "@posthog/shared/constants"; +import { + buildTracesEndpoint, + OTEL_BATCH_DELAY_MS, + OTEL_TRACE_SAMPLE_RATIO, +} from "@posthog/shared/constants"; import type { TRPCError } from "@trpc/server"; export interface NodeTracingOptions { @@ -40,10 +44,8 @@ export function initNodeTracing( return null; } - const url = `${apiHost}/i/v1/traces`; - try { - new URL(url); - } catch { + const url = buildTracesEndpoint(apiHost); + if (!url) { return null; } @@ -64,7 +66,7 @@ export function initNodeTracing( url, headers: { Authorization: `Bearer ${apiKey}` }, }), - { scheduledDelayMillis: 2000 }, + { scheduledDelayMillis: OTEL_BATCH_DELAY_MS }, ), ], }); diff --git a/packages/workspace-server/src/otel-trace.ts b/packages/workspace-server/src/otel-trace.ts index 08e5dff27..32ca3774a 100644 --- a/packages/workspace-server/src/otel-trace.ts +++ b/packages/workspace-server/src/otel-trace.ts @@ -12,6 +12,9 @@ export function initOtelTracing(): Tracer | null { "process.runtime.version": process.versions.node, }, }); + process.stdout.write( + `[workspace-server] otel tracing ${current ? "enabled" : "disabled (missing/invalid VITE_POSTHOG_API_KEY/HOST)"}\n`, + ); return current?.tracer ?? null; } diff --git a/packages/workspace-server/src/serve.ts b/packages/workspace-server/src/serve.ts index a7454b4d2..a94da333d 100644 --- a/packages/workspace-server/src/serve.ts +++ b/packages/workspace-server/src/serve.ts @@ -32,14 +32,14 @@ const app = createApp({ sharedSecret }); let server: ReturnType | null = null; let shuttingDown = false; -const shutdown = (reason: string) => { +const shutdown = async (reason: string) => { if (shuttingDown) return; shuttingDown = true; process.stdout.write(`[workspace-server] shutdown (${reason})\n`); - void shutdownOtelTracing(); - if (!server) process.exit(0); - server.close(); setTimeout(() => process.exit(0), SHUTDOWN_GRACE_MS).unref(); + server?.close(); + await shutdownOtelTracing().catch(() => {}); + process.exit(0); }; process.on("SIGTERM", () => shutdown("SIGTERM")); diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 9d04a7777..a3a5c7af4 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -60,6 +60,7 @@ import { import { inject, injectable, preDestroy } from "inversify"; import { WORKSPACE_REPOSITORY } from "../../db/identifiers"; import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; +import { getWorkspaceServerTracer } from "../../otel-trace"; import { withSpan } from "../../with-span"; import { POSTHOG_PLUGIN_SERVICE } from "../posthog-plugin/identifiers"; import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; @@ -685,6 +686,7 @@ When creating pull requests, add the following footer at the end of the PR descr ); const acpConnection = await withSpan( + getWorkspaceServerTracer(), "agent.run.start", { "task.id": taskId, diff --git a/packages/workspace-server/src/services/git/service.ts b/packages/workspace-server/src/services/git/service.ts index ab08449aa..6e150421d 100644 --- a/packages/workspace-server/src/services/git/service.ts +++ b/packages/workspace-server/src/services/git/service.ts @@ -46,6 +46,7 @@ import { StashPushSaga } from "@posthog/git/sagas/stash"; import { parseGithubUrl } from "@posthog/git/utils"; import { TypedEventEmitter } from "@posthog/shared"; import { injectable } from "inversify"; +import { getWorkspaceServerTracer } from "../../otel-trace"; import { withSpan } from "../../with-span"; import type { SidebarPrState } from "../workspace/schemas"; import type { @@ -686,7 +687,9 @@ export class GitService extends TypedEventEmitter { remote = "origin", signal?: AbortSignal, ): Promise { + const tracer = getWorkspaceServerTracer(); const pullResult = await withSpan( + tracer, "git.sync.pull", { "git.remote": remote }, () => this.pull(directoryPath, remote, undefined, signal), @@ -700,12 +703,13 @@ export class GitService extends TypedEventEmitter { } const pushResult = await withSpan( + tracer, "git.sync.push", { "git.remote": remote }, () => this.push(directoryPath, remote, undefined, false, signal), ); - const state = await withSpan("git.sync.snapshot", {}, () => + const state = await withSpan(tracer, "git.sync.snapshot", {}, () => this.getStateSnapshot(directoryPath), ); diff --git a/packages/workspace-server/src/with-span.test.ts b/packages/workspace-server/src/with-span.test.ts index 2d5c9a7d2..edd2115a0 100644 --- a/packages/workspace-server/src/with-span.test.ts +++ b/packages/workspace-server/src/with-span.test.ts @@ -1,34 +1,19 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockGetTracer = vi.fn(); - -vi.mock("./otel-trace", () => ({ - getWorkspaceServerTracer: () => mockGetTracer(), -})); +import { describe, expect, it, vi } from "vitest"; +import { withSpan } from "./with-span"; describe("withSpan", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - it.each([ ["resolves the wrapped value", 42, 42], ["resolves objects", { ok: true }, { ok: true }], ])("passes through when no tracer (%s)", async (_case, value, expected) => { - mockGetTracer.mockReturnValue(null); - const { withSpan } = await import("./with-span"); - - await expect(withSpan("op", {}, async () => value)).resolves.toEqual( + await expect(withSpan(null, "op", {}, async () => value)).resolves.toEqual( expected, ); }); it("propagates errors when no tracer", async () => { - mockGetTracer.mockReturnValue(null); - const { withSpan } = await import("./with-span"); - await expect( - withSpan("op", {}, async () => { + withSpan(null, "op", {}, async () => { throw new Error("boom"); }), ).rejects.toThrow("boom"); @@ -40,17 +25,16 @@ describe("withSpan", () => { setStatus: vi.fn(), end: vi.fn(), }; - mockGetTracer.mockReturnValue({ + const tracer = { startActiveSpan: ( _name: string, _opts: unknown, fn: (s: unknown) => unknown, ) => fn(span), - }); - const { withSpan } = await import("./with-span"); + } as never; await expect( - withSpan("op", { a: 1 }, async () => { + withSpan(tracer, "op", { a: 1 }, async () => { throw new Error("boom"); }), ).rejects.toThrow("boom"); diff --git a/packages/workspace-server/src/with-span.ts b/packages/workspace-server/src/with-span.ts index 4c73082e1..12008c962 100644 --- a/packages/workspace-server/src/with-span.ts +++ b/packages/workspace-server/src/with-span.ts @@ -1,12 +1,15 @@ -import { type Attributes, SpanStatusCode } from "@opentelemetry/api"; -import { getWorkspaceServerTracer } from "./otel-trace"; +import { + type Attributes, + SpanStatusCode, + type Tracer, +} from "@opentelemetry/api"; export async function withSpan( + tracer: Tracer | null, name: string, attributes: Attributes, fn: () => Promise, ): Promise { - const tracer = getWorkspaceServerTracer(); if (!tracer) { return fn(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd05e99d0..4604dd3ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1455,6 +1455,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: ^4.0.10 + version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) packages/workspace-server: dependencies: From 3f2dcfc406d54c167e2720fd15c12ec7f3074ef1 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sun, 14 Jun 2026 22:29:42 +0100 Subject: [PATCH 3/5] test(otel): Add withSpan success-path coverage (Greptile review) Covers the happy path: tracer present, fn resolves, span ends, value returned unchanged, no exception/error-status recorded. Generated-By: PostHog Code Task-Id: afd9489f-f2f8-4582-ab17-ace0d331ca0b --- .../workspace-server/src/with-span.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/workspace-server/src/with-span.test.ts b/packages/workspace-server/src/with-span.test.ts index edd2115a0..6793cecc2 100644 --- a/packages/workspace-server/src/with-span.test.ts +++ b/packages/workspace-server/src/with-span.test.ts @@ -19,6 +19,29 @@ describe("withSpan", () => { ).rejects.toThrow("boom"); }); + it("returns the value and ends the span on success", async () => { + const span = { + recordException: vi.fn(), + setStatus: vi.fn(), + end: vi.fn(), + }; + const tracer = { + startActiveSpan: ( + _name: string, + _opts: unknown, + fn: (s: unknown) => unknown, + ) => fn(span), + } as never; + + await expect( + withSpan(tracer, "op", { a: 1 }, async () => "done"), + ).resolves.toBe("done"); + + expect(span.end).toHaveBeenCalled(); + expect(span.recordException).not.toHaveBeenCalled(); + expect(span.setStatus).not.toHaveBeenCalled(); + }); + it("records exceptions and ends the span on error", async () => { const span = { recordException: vi.fn(), From c04e8e3a26d08a4c8650aadc453c277d2958394c Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sun, 14 Jun 2026 22:32:16 +0100 Subject: [PATCH 4/5] fix(otel): Avoid polynomial regex in buildTracesEndpoint (CodeQL) Strip trailing slashes with a linear loop instead of /\/+$/, which CodeQL flagged as a polynomial-time ReDoS on uncontrolled input. Generated-By: PostHog Code Task-Id: afd9489f-f2f8-4582-ab17-ace0d331ca0b --- packages/shared/src/constants.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 66a5dee90..0b494cc2d 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -15,7 +15,11 @@ export const OTEL_TRACE_SAMPLE_RATIO = 0.02; export const OTEL_BATCH_DELAY_MS = 2000; export function buildTracesEndpoint(apiHost: string): string | null { - const url = `${apiHost.replace(/\/+$/, "")}/i/v1/traces`; + let host = apiHost; + while (host.endsWith("/")) { + host = host.slice(0, -1); + } + const url = `${host}/i/v1/traces`; let parsed: URL; try { parsed = new URL(url); From 4e9526ae111da6a022d0047bf66ccce0e9e57790 Mon Sep 17 00:00:00 2001 From: pauldambra Date: Sun, 14 Jun 2026 23:05:54 +0100 Subject: [PATCH 5/5] fix(ci): Drop new vitest dep from workspace-client (Socket gate) react-doctor's Socket check flags vitest@4.0.10 (known CVEs); adding it to a package that had no test runner registered a new baseline diagnostic. Revert the test-runner addition and keep tracePropagationHeaders private. The no-op-when-disabled behaviour is guaranteed by OTel's default NoopTextMapPropagator regardless. Generated-By: PostHog Code Task-Id: afd9489f-f2f8-4582-ab17-ace0d331ca0b --- packages/workspace-client/package.json | 4 +--- packages/workspace-client/src/client.test.ts | 17 ----------------- packages/workspace-client/src/client.ts | 2 +- pnpm-lock.yaml | 3 --- 4 files changed, 2 insertions(+), 24 deletions(-) delete mode 100644 packages/workspace-client/src/client.test.ts diff --git a/packages/workspace-client/package.json b/packages/workspace-client/package.json index b4149f8fa..8db557037 100644 --- a/packages/workspace-client/package.json +++ b/packages/workspace-client/package.json @@ -12,7 +12,6 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "vitest run", "clean": "node ../../scripts/rimraf.mjs .turbo" }, "dependencies": { @@ -32,7 +31,6 @@ "@trpc/tanstack-react-query": "catalog:", "@types/react": "catalog:", "react": "catalog:", - "typescript": "catalog:", - "vitest": "^4.0.10" + "typescript": "catalog:" } } diff --git a/packages/workspace-client/src/client.test.ts b/packages/workspace-client/src/client.test.ts deleted file mode 100644 index 8ad599053..000000000 --- a/packages/workspace-client/src/client.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { tracePropagationHeaders } from "./client"; - -describe("tracePropagationHeaders", () => { - it("adds no traceparent when tracing is disabled (no propagator registered)", () => { - const base = { "x-workspace-secret": "abc" }; - const out = tracePropagationHeaders(base); - expect(out).toEqual(base); - expect(out).not.toHaveProperty("traceparent"); - }); - - it("does not mutate the input headers object", () => { - const base = { "x-workspace-secret": "abc" }; - tracePropagationHeaders(base); - expect(base).toEqual({ "x-workspace-secret": "abc" }); - }); -}); diff --git a/packages/workspace-client/src/client.ts b/packages/workspace-client/src/client.ts index daafe48a1..64bdb95de 100644 --- a/packages/workspace-client/src/client.ts +++ b/packages/workspace-client/src/client.ts @@ -10,7 +10,7 @@ import superjson from "superjson"; const SECRET_HEADER = "x-workspace-secret"; -export function tracePropagationHeaders( +function tracePropagationHeaders( base: Record, ): Record { const carrier: Record = { ...base }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4604dd3ff..bd05e99d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1455,9 +1455,6 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 - vitest: - specifier: ^4.0.10 - version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) packages/workspace-server: dependencies: