Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
*/

import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { loadConfig, type PluginConfig, parseBooleanEnv } from "./client"
import manifest from "../package.json" with { type: "json" }
import { BraintrustClient, loadConfig, type PluginConfig, parseBooleanEnv } from "./client"

describe("parseBooleanEnv", () => {
it("returns false for undefined", () => {
Expand Down Expand Up @@ -294,3 +295,42 @@ 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 () => {
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(manifest.version)
})
})
62 changes: 61 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Braintrust API client for the OpenCode plugin
*/

import { PLUGIN_VERSION } from "./version"

export interface BraintrustConfig {
apiKey: string
apiUrl?: string
Expand Down Expand Up @@ -60,6 +62,7 @@ export interface SpanData {
caller_functionname?: string
caller_filename?: string
caller_lineno?: number
span_origin?: Record<string, unknown>
}
span_attributes?: {
name?: string
Expand All @@ -68,6 +71,62 @@ 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 || 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" }
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.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 }
}
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
Expand Down Expand Up @@ -345,7 +404,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,
Expand Down
67 changes: 67 additions & 0 deletions src/git-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -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({})
})
})
53 changes: 53 additions & 0 deletions src/git-metadata.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}),
}
}
62 changes: 62 additions & 0 deletions src/tracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -381,6 +407,42 @@ 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,
enableTools: false,
},
)

const eventHook = hooks.event as (args: { event: unknown }) => Promise<void>
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"
Expand Down
3 changes: 3 additions & 0 deletions src/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
5 changes: 5 additions & 0 deletions src/version.ts
Original file line number Diff line number Diff line change
@@ -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
Loading