From 5650f3758a21091ec5948ce84d54a1fc8afd728b Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 7 Jul 2026 22:41:02 +0800 Subject: [PATCH 1/8] Capture repo attribution metadata --- src/git-metadata.test.ts | 67 ++++++++++++++++++++++++++++++++++++++++ src/git-metadata.ts | 53 +++++++++++++++++++++++++++++++ src/tracing.test.ts | 61 ++++++++++++++++++++++++++++++++++++ src/tracing.ts | 3 ++ 4 files changed, 184 insertions(+) create mode 100644 src/git-metadata.test.ts create mode 100644 src/git-metadata.ts diff --git a/src/git-metadata.test.ts b/src/git-metadata.test.ts new file mode 100644 index 0000000..01621d6 --- /dev/null +++ b/src/git-metadata.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { gitMetadataForCwd, redactGitRemoteUrl } from "./git-metadata" + +const tempDirs: string[] = [] + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }) + } +}) + +function makeGitRepo(): { dir: string; commit: string } { + const dir = mkdtempSync(join(tmpdir(), "opencode-git-metadata-")) + tempDirs.push(dir) + execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" }) + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir }) + execFileSync("git", ["config", "user.name", "Test User"], { cwd: dir }) + writeFileSync(join(dir, "README.md"), "hello\n") + execFileSync("git", ["add", "README.md"], { cwd: dir }) + execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" }) + execFileSync("git", ["branch", "-M", "main"], { cwd: dir }) + execFileSync("git", ["remote", "add", "origin", "https://token@github.com/acme/app.git"], { + cwd: dir, + }) + const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim() + return { dir, commit } +} + +describe("git metadata", () => { + it("redacts credentials from URL-style remotes", () => { + expect(redactGitRemoteUrl("https://token@example.com/org/repo.git")).toBe( + "https://example.com/org/repo.git", + ) + expect(redactGitRemoteUrl("git@github.com:org/repo.git")).toBe("git@github.com:org/repo.git") + }) + + it("captures origin, branch, and commit", () => { + const repo = makeGitRepo() + + expect(gitMetadataForCwd(repo.dir)).toEqual({ + git_origin_url: "https://github.com/acme/app.git", + git_branch: "main", + git_commit_sha: repo.commit, + }) + }) + + it("omits branch for detached HEAD", () => { + const repo = makeGitRepo() + execFileSync("git", ["checkout", "--detach", "HEAD"], { cwd: repo.dir, stdio: "ignore" }) + + expect(gitMetadataForCwd(repo.dir)).toEqual({ + git_origin_url: "https://github.com/acme/app.git", + git_commit_sha: repo.commit, + }) + }) + + it("omits all fields outside a git repository", () => { + const dir = mkdtempSync(join(tmpdir(), "opencode-not-git-")) + tempDirs.push(dir) + + expect(gitMetadataForCwd(dir)).toEqual({}) + }) +}) diff --git a/src/git-metadata.ts b/src/git-metadata.ts new file mode 100644 index 0000000..c7141d1 --- /dev/null +++ b/src/git-metadata.ts @@ -0,0 +1,53 @@ +import { spawnSync } from "node:child_process" + +export interface GitMetadata { + git_origin_url?: string + git_branch?: string + git_commit_sha?: string +} + +function runGit(cwd: string, args: string[]): string | undefined { + try { + const result = spawnSync("git", ["-C", cwd, ...args], { + encoding: "utf8", + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + timeout: 500, + windowsHide: true, + }) + if (result.status !== 0 || typeof result.stdout !== "string") return undefined + const value = result.stdout.trim() + return value.length > 0 ? value : undefined + } catch { + return undefined + } +} + +export function redactGitRemoteUrl(remoteUrl: string): string { + const trimmed = remoteUrl.trim() + if (!trimmed) return trimmed + + try { + const parsed = new URL(trimmed) + if (parsed.username || parsed.password) { + parsed.username = "" + parsed.password = "" + } + return parsed.toString() + } catch { + return trimmed + } +} + +export function gitMetadataForCwd(cwd: string | undefined): GitMetadata { + if (!cwd) return {} + + const origin = runGit(cwd, ["remote", "get-url", "origin"]) + const branch = runGit(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]) + const commit = runGit(cwd, ["rev-parse", "HEAD"]) + + return { + ...(origin ? { git_origin_url: redactGitRemoteUrl(origin) } : {}), + ...(branch ? { git_branch: branch } : {}), + ...(commit ? { git_commit_sha: commit } : {}), + } +} diff --git a/src/tracing.test.ts b/src/tracing.test.ts index 2d38004..4b3b053 100644 --- a/src/tracing.test.ts +++ b/src/tracing.test.ts @@ -5,6 +5,7 @@ */ import { afterEach, describe, expect, it } from "bun:test" +import { execFileSync } from "node:child_process" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" @@ -33,12 +34,37 @@ import { } from "./test-helpers" import { createTracingHooks } from "./tracing" +const tempDirs: string[] = [] + +afterEach(() => { + while (tempDirs.length > 0) { + fs.rmSync(tempDirs.pop()!, { recursive: true, force: true }) + } +}) + function expectUnixSecondsTimestamp(value: number | undefined): void { expect(value).toBeDefined() expect(value!).toBeGreaterThan(1_000_000_000) expect(value!).toBeLessThan(10_000_000_000) } +function makeGitRepo(): { dir: string; commit: string } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-root-git-")) + tempDirs.push(dir) + execFileSync("git", ["init"], { cwd: dir, stdio: "ignore" }) + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir }) + execFileSync("git", ["config", "user.name", "Test User"], { cwd: dir }) + fs.writeFileSync(path.join(dir, "README.md"), "hello\n") + execFileSync("git", ["add", "README.md"], { cwd: dir }) + execFileSync("git", ["commit", "-m", "init"], { cwd: dir, stdio: "ignore" }) + execFileSync("git", ["branch", "-M", "main"], { cwd: dir }) + execFileSync("git", ["remote", "add", "origin", "https://token@github.com/acme/app.git"], { + cwd: dir, + }) + const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim() + return { dir, commit } +} + describe("Event to Span Transformation", () => { it("session -> turn -> llm", async () => { const sessionId = "ses_1" @@ -381,6 +407,41 @@ describe("Metric timestamps use Unix seconds", () => { expectUnixSecondsTimestamp(llmSpan?.metrics?.end) }) + it("adds minimal git attribution metadata to production root spans", async () => { + const repo = makeGitRepo() + const sessionId = "ses_hooks_git" + const collector = new TestSpanCollector() + const hooks = createTracingHooks( + collector, + { + client: { + app: { + log: async () => undefined, + }, + }, + worktree: repo.dir, + directory: repo.dir, + } as any, + { + apiKey: "", + apiUrl: "https://api.braintrust.dev", + appUrl: "https://www.braintrust.dev", + projectName: "test-project", + tracingEnabled: true, + debug: false, + }, + ) + + const eventHook = hooks.event as (args: { event: unknown }) => Promise + await eventHook({ event: sessionCreated(sessionId) }) + + expect(collector.getSpans()[0]?.metadata).toMatchObject({ + git_origin_url: "https://github.com/acme/app.git", + git_branch: "main", + git_commit_sha: repo.commit, + }) + }) + it("converts createTracingHooks span metrics from milliseconds to seconds", async () => { const sessionId = "ses_hooks_seconds" const messageId = "msg_hooks_seconds" diff --git a/src/tracing.ts b/src/tracing.ts index 48f5bf1..574943d 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -14,6 +14,7 @@ import type { BraintrustConfig, SpanData } from "./client" import { msToSeconds, wallClock } from "./clock" import { extractToolOutput } from "./event-processor" import type { FileLogger } from "./file-logger" +import { gitMetadataForCwd } from "./git-metadata" import type { SpanQueue } from "./span-queue" import type { SpanSink } from "./span-sink" @@ -312,6 +313,7 @@ export function createTracingHooks( session_id: sessionKey, workspace: input.worktree, directory: input.directory, + ...gitMetadataForCwd(input.directory || input.worktree), hostname: getHostname(), username: getUsername(), os: getOS(), @@ -833,6 +835,7 @@ export function createTracingHooks( session_id: sessionID, workspace: input.worktree, directory: input.directory, + ...gitMetadataForCwd(input.directory || input.worktree), hostname: getHostname(), username: getUsername(), os: getOS(), From 2f11877083f32e58e25ac11ae0d2fc8567922342 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Tue, 7 Jul 2026 23:06:30 +0800 Subject: [PATCH 2/8] Fix repo metadata test config --- src/tracing.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tracing.test.ts b/src/tracing.test.ts index 4b3b053..50d56aa 100644 --- a/src/tracing.test.ts +++ b/src/tracing.test.ts @@ -429,6 +429,7 @@ describe("Metric timestamps use Unix seconds", () => { projectName: "test-project", tracingEnabled: true, debug: false, + enableTools: false, }, ) From f0713740ab7720363f05500fbb5eb9bc39e50880 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Mon, 13 Jul 2026 23:28:39 +0800 Subject: [PATCH 3/8] Add span origin provenance --- src/client.ts | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index afc8008..db9d060 100644 --- a/src/client.ts +++ b/src/client.ts @@ -60,6 +60,7 @@ export interface SpanData { caller_functionname?: string caller_filename?: string caller_lineno?: number + span_origin?: Record } span_attributes?: { name?: string @@ -68,6 +69,46 @@ export interface SpanData { _is_merge?: boolean // When true, merge with existing span by id instead of creating new row } +const PLUGIN_VERSION = "0.0.9" + +function detectEnvironment(): { type: string; name?: string } | undefined { + if (process.env.BRAINTRUST_ENVIRONMENT_TYPE) { + return process.env.BRAINTRUST_ENVIRONMENT_NAME + ? { type: process.env.BRAINTRUST_ENVIRONMENT_TYPE, name: process.env.BRAINTRUST_ENVIRONMENT_NAME } + : { type: process.env.BRAINTRUST_ENVIRONMENT_TYPE } + } + if (process.env.GITHUB_ACTIONS) return { type: "ci", name: "github_actions" } + if (process.env.GITLAB_CI) return { type: "ci", name: "gitlab_ci" } + if (process.env.CIRCLECI) return { type: "ci", name: "circleci" } + if (process.env.BUILDKITE) return { type: "ci", name: "buildkite" } + if (process.env.CI) return { type: "ci", name: "ci" } + if (process.env.VERCEL) return { type: "server", name: "vercel" } + if (process.env.NETLIFY) return { type: "server", name: "netlify" } + if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "staging") { + return { type: "server", name: process.env.NODE_ENV } + } + if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "local") { + return { type: "local", name: process.env.NODE_ENV } + } + return undefined +} + +function withSpanOrigin(span: SpanData): SpanData { + const environment = detectEnvironment() + return { + ...span, + context: { + ...(span.context ?? {}), + span_origin: { + name: "braintrust.plugin.opencode", + version: PLUGIN_VERSION, + instrumentation: { name: "opencode-tracing" }, + ...(environment ? { environment } : {}), + }, + }, + } +} + interface LoginResponse { org_info: Array<{ name: string @@ -345,7 +386,8 @@ export class BraintrustClient { } try { - const payload = { events: [span] } + const enrichedSpan = withSpanOrigin(span) + const payload = { events: [enrichedSpan] } debugLog?.("insertSpan: sending", { spanId: span.span_id, isMerge: span._is_merge, From b488d94f84815c2efbd0ff36803877eeab91a4ee Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 14:48:25 +0800 Subject: [PATCH 4/8] Format environment provenance helper --- src/client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index db9d060..1cf464b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -74,7 +74,10 @@ const PLUGIN_VERSION = "0.0.9" function detectEnvironment(): { type: string; name?: string } | undefined { if (process.env.BRAINTRUST_ENVIRONMENT_TYPE) { return process.env.BRAINTRUST_ENVIRONMENT_NAME - ? { type: process.env.BRAINTRUST_ENVIRONMENT_TYPE, name: process.env.BRAINTRUST_ENVIRONMENT_NAME } + ? { + type: process.env.BRAINTRUST_ENVIRONMENT_TYPE, + name: process.env.BRAINTRUST_ENVIRONMENT_NAME, + } : { type: process.env.BRAINTRUST_ENVIRONMENT_TYPE } } if (process.env.GITHUB_ACTIONS) return { type: "ci", name: "github_actions" } From 1fd0187beef3e2c9c024f950a597bfd16e5c3425 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 15:13:36 +0800 Subject: [PATCH 5/8] Load OpenCode plugin version from package metadata --- src/client.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++++++- src/client.ts | 15 ++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/client.test.ts b/src/client.test.ts index 1c3c819..1c1e5ec 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -3,7 +3,8 @@ */ import { afterEach, beforeEach, describe, expect, it } from "bun:test" -import { loadConfig, type PluginConfig, parseBooleanEnv } from "./client" +import { readFileSync } from "node:fs" +import { BraintrustClient, loadConfig, type PluginConfig, parseBooleanEnv } from "./client" describe("parseBooleanEnv", () => { it("returns false for undefined", () => { @@ -294,3 +295,45 @@ describe("loadConfig", () => { }) }) }) + +describe("BraintrustClient span origin", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + it("uses the package.json version for span origin provenance", async () => { + const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { version: string } + let payload: { events?: Array<{ context?: { span_origin?: { version?: string } } }> } = {} + globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + payload = JSON.parse(String(init?.body)) + return new Response(JSON.stringify({ row_ids: ["row-id"] }), { status: 200 }) + }) as typeof fetch + + const client = new BraintrustClient({ + apiKey: "key", + apiUrl: "https://api.example.com", + appUrl: "https://app.example.com", + projectName: "project", + tracingEnabled: true, + enableTools: true, + debug: false, + }) + Object.assign(client, { + initPromise: Promise.resolve(), + projectId: "project-id", + resolvedApiUrl: "https://api.example.com", + }) + + await client.insertSpan({ + id: "span-id", + span_id: "span-id", + root_span_id: "span-id", + }) + + expect(payload.events?.[0]?.context?.span_origin?.version).toBe(packageJson.version) + }) +}) diff --git a/src/client.ts b/src/client.ts index 1cf464b..7e2c15e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,6 +2,8 @@ * Braintrust API client for the OpenCode plugin */ +import { readFileSync } from "node:fs" + export interface BraintrustConfig { apiKey: string apiUrl?: string @@ -69,7 +71,18 @@ export interface SpanData { _is_merge?: boolean // When true, merge with existing span by id instead of creating new row } -const PLUGIN_VERSION = "0.0.9" +function loadPluginVersion(): string { + try { + const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { version?: unknown } + return typeof packageJson.version === "string" ? packageJson.version : "unknown" + } catch { + return "unknown" + } +} + +const PLUGIN_VERSION = loadPluginVersion() function detectEnvironment(): { type: string; name?: string } | undefined { if (process.env.BRAINTRUST_ENVIRONMENT_TYPE) { From 3a981bed41a06427c868a08e5d3049d6628f90d5 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 15:19:48 +0800 Subject: [PATCH 6/8] Import OpenCode plugin version metadata --- src/client.test.ts | 7 ++----- src/client.ts | 15 +-------------- src/version.ts | 5 +++++ 3 files changed, 8 insertions(+), 19 deletions(-) create mode 100644 src/version.ts diff --git a/src/client.test.ts b/src/client.test.ts index 1c1e5ec..6685d3c 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -3,7 +3,7 @@ */ import { afterEach, beforeEach, describe, expect, it } from "bun:test" -import { readFileSync } from "node:fs" +import manifest from "../package.json" with { type: "json" } import { BraintrustClient, loadConfig, type PluginConfig, parseBooleanEnv } from "./client" describe("parseBooleanEnv", () => { @@ -304,9 +304,6 @@ describe("BraintrustClient span origin", () => { }) it("uses the package.json version for span origin provenance", async () => { - const packageJson = JSON.parse( - readFileSync(new URL("../package.json", import.meta.url), "utf8"), - ) as { version: string } let payload: { events?: Array<{ context?: { span_origin?: { version?: string } } }> } = {} globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { payload = JSON.parse(String(init?.body)) @@ -334,6 +331,6 @@ describe("BraintrustClient span origin", () => { root_span_id: "span-id", }) - expect(payload.events?.[0]?.context?.span_origin?.version).toBe(packageJson.version) + expect(payload.events?.[0]?.context?.span_origin?.version).toBe(manifest.version) }) }) diff --git a/src/client.ts b/src/client.ts index 7e2c15e..c0df8fa 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,7 +2,7 @@ * Braintrust API client for the OpenCode plugin */ -import { readFileSync } from "node:fs" +import { PLUGIN_VERSION } from "./version" export interface BraintrustConfig { apiKey: string @@ -71,19 +71,6 @@ export interface SpanData { _is_merge?: boolean // When true, merge with existing span by id instead of creating new row } -function loadPluginVersion(): string { - try { - const packageJson = JSON.parse( - readFileSync(new URL("../package.json", import.meta.url), "utf8"), - ) as { version?: unknown } - return typeof packageJson.version === "string" ? packageJson.version : "unknown" - } catch { - return "unknown" - } -} - -const PLUGIN_VERSION = loadPluginVersion() - function detectEnvironment(): { type: string; name?: string } | undefined { if (process.env.BRAINTRUST_ENVIRONMENT_TYPE) { return process.env.BRAINTRUST_ENVIRONMENT_NAME diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..b01a794 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,5 @@ +// Read the package version at build/bundle time so package.json remains the +// single source of truth for span origin provenance. +import manifest from "../package.json" with { type: "json" } + +export const PLUGIN_VERSION: string = manifest.version From 7a5a60cd24b8c2c04303767c413db03a7582a808 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 18:10:02 +0800 Subject: [PATCH 7/8] Fix span origin AWS environment detection --- src/client.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/client.ts b/src/client.ts index c0df8fa..f418d3d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -87,6 +87,19 @@ function detectEnvironment(): { type: string; name?: string } | undefined { if (process.env.CI) return { type: "ci", name: "ci" } if (process.env.VERCEL) return { type: "server", name: "vercel" } if (process.env.NETLIFY) return { type: "server", name: "netlify" } + if ( + process.env.ECS_CONTAINER_METADATA_URI || + process.env.ECS_CONTAINER_METADATA_URI_V4 || + process.env.AWS_EXECUTION_ENV?.startsWith("AWS_ECS_") + ) { + return { type: "server", name: "ecs" } + } + if ( + process.env.AWS_LAMBDA_FUNCTION_NAME || + process.env.AWS_EXECUTION_ENV?.startsWith("AWS_Lambda_") + ) { + return { type: "server", name: "aws_lambda" } + } if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "staging") { return { type: "server", name: process.env.NODE_ENV } } From 838dd3d5136313551518d3756197faf60a560cf0 Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Wed, 15 Jul 2026 22:51:11 +0800 Subject: [PATCH 8/8] fix: preserve span origin environment name --- src/client.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/client.ts b/src/client.ts index f418d3d..eb440d2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -71,14 +71,16 @@ export interface SpanData { _is_merge?: boolean // When true, merge with existing span by id instead of creating new row } -function detectEnvironment(): { type: string; name?: string } | undefined { - if (process.env.BRAINTRUST_ENVIRONMENT_TYPE) { - return process.env.BRAINTRUST_ENVIRONMENT_NAME - ? { - type: process.env.BRAINTRUST_ENVIRONMENT_TYPE, - name: process.env.BRAINTRUST_ENVIRONMENT_NAME, - } - : { type: process.env.BRAINTRUST_ENVIRONMENT_TYPE } +function detectEnvironment(): { type?: string; name?: string } | undefined { + if (process.env.BRAINTRUST_ENVIRONMENT_TYPE || process.env.BRAINTRUST_ENVIRONMENT_NAME) { + return { + ...(process.env.BRAINTRUST_ENVIRONMENT_TYPE + ? { type: process.env.BRAINTRUST_ENVIRONMENT_TYPE } + : {}), + ...(process.env.BRAINTRUST_ENVIRONMENT_NAME + ? { name: process.env.BRAINTRUST_ENVIRONMENT_NAME } + : {}), + } } if (process.env.GITHUB_ACTIONS) return { type: "ci", name: "github_actions" } if (process.env.GITLAB_CI) return { type: "ci", name: "gitlab_ci" }