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 b06a396..cabca69 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,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 + 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(),