diff --git a/scripts/pipboy/src/cli.ts b/scripts/pipboy/src/cli.ts index 1dce907589..81cbb06402 100644 --- a/scripts/pipboy/src/cli.ts +++ b/scripts/pipboy/src/cli.ts @@ -1,3 +1,4 @@ +import { tmpdir } from "node:os"; import { join } from "node:path"; import { NodeHttpClient, @@ -11,13 +12,16 @@ import { FileSystem, Layer, Option, + Redacted, Schema, } from "effect"; import { Command, Flag } from "effect/cli"; +import { HttpClient, HttpClientRequest } from "effect/http"; import { compareFootprint } from "./compare/footprint.ts"; import { compareHygiene } from "./compare/hygiene.ts"; import { compareNative } from "./compare/native.ts"; import { REPO_ROOT } from "./config.ts"; +import { run } from "./exec.ts"; import { inspectEntrypoints } from "./footprint/inspect.ts"; import { pack, readPackManifest } from "./footprint/pack.ts"; import * as Registry from "./footprint/registry.ts"; @@ -32,9 +36,11 @@ import { renderFootprint } from "./render/footprint.ts"; import { renderHygiene } from "./render/hygiene.ts"; import { renderNative } from "./render/native.ts"; import { JobResults, renderCheckSection } from "./render/status.ts"; +import { renderTraceHelp } from "./render/trace.ts"; import { FootprintReport, type FootprintSide } from "./schema/footprint.ts"; import { HygieneReport } from "./schema/hygiene.ts"; import { NativeReport } from "./schema/native.ts"; +import { commitTrace } from "./trace/commit.ts"; export class ChecksFailed extends Schema.TaggedError()( "ChecksFailed", @@ -241,7 +247,7 @@ const reportCommand = Command.make( Effect.fn("Cli.report")( function* ({ reports, baseHistory }) { const fs = yield* FileSystem.FileSystem; - const github = yield* GitHubApi.GitHub; + const currentRun = yield* GitHubApi.CurrentRun; const env = yield* Config.all({ // Empty for pushes and fork PRs, which get the run summary instead of a comment. prNumber: Config.String("PR_NUMBER").pipe(Config.withDefault("")), @@ -261,7 +267,7 @@ const reportCommand = Command.make( renderCheckSection( { heading, job, skipReason, rendered }, results, - github.runUrl, + currentRun.url, ); const sections: Array = [ @@ -345,7 +351,7 @@ const reportCommand = Command.make( ); const timing = yield* readTiming(); - sections.push(renderTiming(timing)); + sections.push(renderTiming(timing), renderTraceHelp(env.headSha)); const body = sections.join("\n\n"); if (Option.isSome(env.summary)) { @@ -357,7 +363,7 @@ const reportCommand = Command.make( const result = yield* upsertComment( Number(env.prNumber), env.headSha, - fitComment(body, github.runUrl), + fitComment(body, currentRun.url), ); yield* Console.log(`comment ${result}`); } @@ -371,6 +377,70 @@ const reportCommand = Command.make( ), ); +export class InvalidSha extends Schema.TaggedError()("InvalidSha", { + sha: Schema.String, +}) { + override get message() { + return `--sha must be a full 40-character commit SHA, got \`${this.sha}\``; + } +} + +const traceCommand = Command.make( + "trace", + { + sha: Flag.String("sha").pipe( + Flag.withDescription("Commit to trace (full SHA)"), + ), + repo: Flag.String("repo").pipe(Flag.withDefault("rivet-dev/agentos")), + out: Flag.String("out").pipe( + Flag.optional, + Flag.withDescription("Write the OTLP/JSON trace to this file"), + ), + otlp: Flag.String("otlp").pipe( + Flag.optional, + Flag.withDescription( + "Send the trace to this OTLP/HTTP endpoint, such as motel at http://127.0.0.1:27686", + ), + ), + }, + Effect.fn("Cli.trace")(function* ({ sha, repo, out, otlp }) { + if (!/^[0-9a-f]{40}$/.test(sha)) return yield* new InvalidSha({ sha }); + const fs = yield* FileSystem.FileSystem; + // Locally, fall back to the GitHub CLI's login so no token needs exporting. + const token = + Option.getOrUndefined( + yield* Config.option(Config.Redacted("GITHUB_TOKEN")), + ) ?? Redacted.make((yield* run("gh", ["auth", "token"])).trim()); + const { runs, document } = yield* Effect.scoped(commitTrace(sha)).pipe( + Effect.provide(GitHubApi.layerFor(repo, token)), + ); + const spans = document.resourceSpans[0]?.scopeSpans[0]?.spans.length ?? 0; + + // The default stays out of the checkout, where version control would pick it up. + const path = Option.getOrElse(out, () => + join(tmpdir(), `pipboy-trace-${sha.slice(0, 7)}.json`), + ); + yield* fs.writeFileString(path, JSON.stringify(document)); + yield* Console.log(`${runs} workflow runs, ${spans} spans → ${path}`); + if (Option.isSome(otlp)) { + const http = (yield* HttpClient.HttpClient).pipe( + HttpClient.filterStatusOk, + ); + yield* HttpClientRequest.post( + `${otlp.value.replace(/\/$/, "")}/v1/traces`, + ).pipe( + HttpClientRequest.bodyJson(document), + Effect.flatMap(http.execute), + ); + yield* Console.log(`sent to ${otlp.value}`); + } + }), +).pipe( + Command.withDescription( + "Build one trace of the CI that ran on a commit: CI, Pip-Boy, and releases", + ), +); + export class ReportTooLarge extends Schema.TaggedError()( "ReportTooLarge", { @@ -422,6 +492,7 @@ const cli = Command.make("pipboy").pipe( nativeCommand, baselineCommand, reportCommand, + traceCommand, ]), ); diff --git a/scripts/pipboy/src/github/api.ts b/scripts/pipboy/src/github/api.ts index 7d33fdf080..a1aa59ff93 100644 --- a/scripts/pipboy/src/github/api.ts +++ b/scripts/pipboy/src/github/api.ts @@ -7,25 +7,8 @@ import { HttpClientResponse, } from "effect/http"; -/** GitHub Actions context, read from the standard runner environment. */ -export const ActionsEnv = Config.all({ - token: Config.Redacted("GITHUB_TOKEN"), - repository: Config.String("GITHUB_REPOSITORY"), - runId: Config.String("GITHUB_RUN_ID"), - runAttempt: Config.String("GITHUB_RUN_ATTEMPT").pipe(Config.withDefault("1")), - apiUrl: Config.String("GITHUB_API_URL").pipe( - Config.withDefault("https://api.github.com"), - ), - serverUrl: Config.String("GITHUB_SERVER_URL").pipe( - Config.withDefault("https://github.com"), - ), -}); - export interface Interface { readonly repository: string; - readonly runId: string; - readonly runAttempt: string; - readonly runUrl: string; readonly get: ( path: string, schema: S, @@ -48,39 +31,67 @@ export class GitHub extends Context.Service()( "pipboy/GitHub", ) {} -export const layer = Layer.effect( - GitHub, - Effect.gen(function* () { - const env = yield* ActionsEnv; - const http = (yield* HttpClient.HttpClient).pipe( - HttpClient.mapRequest((request) => - request.pipe( - HttpClientRequest.prependUrl(env.apiUrl), - HttpClientRequest.bearerToken(Redacted.value(env.token)), - HttpClientRequest.setHeader("accept", "application/vnd.github+json"), - HttpClientRequest.setHeader("x-github-api-version", "2022-11-28"), - // GitHub rejects API requests without a User-Agent. - HttpClientRequest.setHeader("user-agent", "rivet-pipboy"), - ), - ), - HttpClient.filterStatusOk, - HttpClient.retryTransient({ times: 4 }), - ); - return GitHub.of({ - repository: env.repository, - runId: env.runId, - runAttempt: env.runAttempt, - runUrl: `${env.serverUrl}/${env.repository}/actions/runs/${env.runId}`, - get: (path, schema) => - http - .get(path) - .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema))), - send: (method, path, body) => - HttpClientRequest.make(method)(path).pipe( - HttpClientRequest.bodyJson(body), - Effect.flatMap(http.execute), - Effect.asVoid, +/** The current GitHub Actions run, read from the standard runner environment. */ +export const CurrentRun = Config.all({ + repository: Config.String("GITHUB_REPOSITORY"), + runId: Config.String("GITHUB_RUN_ID"), + runAttempt: Config.String("GITHUB_RUN_ATTEMPT").pipe(Config.withDefault("1")), + serverUrl: Config.String("GITHUB_SERVER_URL").pipe( + Config.withDefault("https://github.com"), + ), +}).pipe( + Config.map((run) => ({ + ...run, + url: `${run.serverUrl}/${run.repository}/actions/runs/${run.runId}`, + })), +); + +/** A GitHub API client for `repository`, authenticated with `token` or `GITHUB_TOKEN`. */ +export const layerFor = ( + repository: string, + explicitToken?: Redacted.Redacted, +) => + Layer.effect( + GitHub, + Effect.gen(function* () { + const token = explicitToken ?? (yield* Config.Redacted("GITHUB_TOKEN")); + const apiUrl = yield* Config.String("GITHUB_API_URL").pipe( + Config.withDefault("https://api.github.com"), + ); + const http = (yield* HttpClient.HttpClient).pipe( + HttpClient.mapRequest((request) => + request.pipe( + HttpClientRequest.prependUrl(apiUrl), + HttpClientRequest.bearerToken(Redacted.value(token)), + HttpClientRequest.setHeader( + "accept", + "application/vnd.github+json", + ), + HttpClientRequest.setHeader("x-github-api-version", "2022-11-28"), + // GitHub rejects API requests without a User-Agent. + HttpClientRequest.setHeader("user-agent", "rivet-pipboy"), + ), ), - }); - }), + HttpClient.filterStatusOk, + HttpClient.retryTransient({ times: 4 }), + ); + return GitHub.of({ + repository, + get: (path, schema) => + http + .get(path) + .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema))), + send: (method, path, body) => + HttpClientRequest.make(method)(path).pipe( + HttpClientRequest.bodyJson(body), + Effect.flatMap(http.execute), + Effect.asVoid, + ), + }); + }), + ); + +/** A client for the repository of the current Actions run. */ +export const layer = Layer.unwrap( + Config.String("GITHUB_REPOSITORY").pipe(Effect.map(layerFor)), ); diff --git a/scripts/pipboy/src/github/timing.ts b/scripts/pipboy/src/github/timing.ts index 07f53670ff..68363bf803 100644 --- a/scripts/pipboy/src/github/timing.ts +++ b/scripts/pipboy/src/github/timing.ts @@ -1,6 +1,7 @@ import { Effect, Schema } from "effect"; import { code } from "../render/format.ts"; -import { GitHub } from "./api.ts"; +import { stepLabel, toMillis } from "../trace/spans.ts"; +import { CurrentRun, GitHub } from "./api.ts"; const Step = Schema.Struct({ name: Schema.String, @@ -14,6 +15,7 @@ const Job = Schema.Struct({ id: Schema.Number, name: Schema.String, conclusion: Schema.NullOr(Schema.String), + created_at: Schema.optionalKey(Schema.String), started_at: Schema.NullOr(Schema.String), completed_at: Schema.NullOr(Schema.String), html_url: Schema.optionalKey(Schema.String), @@ -29,35 +31,33 @@ export interface Timing { readonly now: number; } -/** Job and step timings of the current run, including still-running jobs. */ -export const readTiming = Effect.fn("Timing.read")(function* () { +/** Every job of one run attempt, including still-running jobs. */ +export const readJobs = Effect.fn("Timing.readJobs")(function* ( + runId: number | string, + attempt: number | string, +) { const github = yield* GitHub; - const { jobs } = yield* github.get( - `/repos/${github.repository}/actions/runs/${github.runId}/attempts/${github.runAttempt}/jobs?per_page=100`, - Jobs, - ); + const jobs: Array = []; + for (let page = 1; ; page++) { + const batch = yield* github.get( + `/repos/${github.repository}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100&page=${page}`, + Jobs, + ); + jobs.push(...batch.jobs); + if (batch.jobs.length < 100) return jobs; + } +}); + +/** Job and step timings of the current run. */ +export const readTiming = Effect.fn("Timing.read")(function* () { + const run = yield* CurrentRun; + const jobs = yield* readJobs(run.runId, run.runAttempt); return { jobs: jobs.filter((job) => job.started_at !== null), now: Date.now(), } satisfies Timing; }); -/** - * Short step names: `Run actions/checkout@` becomes `checkout`. Rendered - * as code, so GitHub never turns `owner/repo@sha` into a commit link or `@name` - * into a mention. - */ -export function stepLabel(name: string): string { - const action = name.match( - /^(Post )?Run [\w.-]+\/([\w.-]+)(?:\/[\w./-]+)?@\S+$/, - ); - if (action) return `${action[1] ? "post " : ""}${action[2]}`; - return name.split("\n")[0] ?? name; -} - -const ms = (iso: string | null, fallback: number) => - iso === null ? fallback : Date.parse(iso); - function duration(totalMs: number): string { const s = Math.max(0, Math.round(totalMs / 1000)); return s >= 60 @@ -74,11 +74,13 @@ export function renderTiming(timing: Timing): string { "|---|---:|---|", ]; for (const job of timing.jobs) { - const start = ms(job.started_at, timing.now); + const start = toMillis(job.started_at, timing.now); const slowest = [...(job.steps ?? [])] .map((s) => ({ name: s.name, - ms: ms(s.completed_at, timing.now) - ms(s.started_at, timing.now), + ms: + toMillis(s.completed_at, timing.now) - + toMillis(s.started_at, timing.now), })) .sort((a, b) => b.ms - a.ms)[0]; const name = @@ -96,7 +98,7 @@ export function renderTiming(timing: Timing): string { ? "⊘ " : ""; lines.push( - `| ${icon}${name} | ${duration(ms(job.completed_at, timing.now) - start)} | ${slowest ? `${code(stepLabel(slowest.name))} · ${duration(slowest.ms)}` : "—"} |`, + `| ${icon}${name} | ${duration(toMillis(job.completed_at, timing.now) - start)} | ${slowest ? `${code(stepLabel(slowest.name))} · ${duration(slowest.ms)}` : "—"} |`, ); } return lines.join("\n"); diff --git a/scripts/pipboy/src/render/trace.ts b/scripts/pipboy/src/render/trace.ts new file mode 100644 index 0000000000..e6e5ba3bfc --- /dev/null +++ b/scripts/pipboy/src/render/trace.ts @@ -0,0 +1,19 @@ +/** Collapsed comment section: how a maintainer or agent pulls the full CI trace for `sha`. */ +export function renderTraceHelp(sha: string): string { + return [ + "
🔭 Full CI trace for this commit", + "", + `One OpenTelemetry trace of CI and Pip-Boy for \`${sha.slice(0, 7)}\`: every job and step, with the sizes above attached. Run after CI finishes.`, + "", + "```bash", + "pnpm install --frozen-lockfile --filter 'pipboy...'", + `pnpm --filter pipboy pipboy trace --sha ${sha}`, + "```", + "", + "Add `--otlp ` to send it to any OTLP/HTTP collector.", + "", + "**Agent instruction:** From a `rivet-dev/agentos` checkout, run the commands above, then read the trace file the last command prints. List the slowest jobs and steps and explain where CI time went.", + "", + "
", + ].join("\n"); +} diff --git a/scripts/pipboy/src/trace/commit.ts b/scripts/pipboy/src/trace/commit.ts new file mode 100644 index 0000000000..24b8c2c3d4 --- /dev/null +++ b/scripts/pipboy/src/trace/commit.ts @@ -0,0 +1,248 @@ +import { join } from "node:path"; +import { Effect, FileSystem, Schema } from "effect"; +import { compareFootprint } from "../compare/footprint.ts"; +import { compareHygiene } from "../compare/hygiene.ts"; +import { run } from "../exec.ts"; +import { GitHub } from "../github/api.ts"; +import { WORKFLOW_PATH } from "../github/baseline.ts"; +import { readJobs } from "../github/timing.ts"; +import { FootprintReport } from "../schema/footprint.ts"; +import { HygieneReport } from "../schema/hygiene.ts"; +import { NativeReport } from "../schema/native.ts"; +import { + type Attribute, + attr, + jobSpans, + otlpDocument, + runSpanId, + span, + toMillis, + traceIdFor, +} from "./spans.ts"; + +const WorkflowRun = Schema.Struct({ + id: Schema.Number, + name: Schema.NullOr(Schema.String), + path: Schema.String, + event: Schema.String, + status: Schema.NullOr(Schema.String), + conclusion: Schema.NullOr(Schema.String), + run_attempt: Schema.Number, + created_at: Schema.String, + run_started_at: Schema.optionalKey(Schema.NullOr(Schema.String)), + updated_at: Schema.String, + html_url: Schema.String, +}); +type WorkflowRun = Schema.Schema.Type; + +const Runs = Schema.Struct({ + total_count: Schema.Number, + workflow_runs: Schema.Array(WorkflowRun), +}); +const Artifacts = Schema.Struct({ + artifacts: Schema.Array( + Schema.Struct({ name: Schema.String, expired: Schema.Boolean }), + ), +}); + +/** + * The workflows that build and test the code, by file. Automation such as docs + * sync is left out of the trace. + */ +const TRACED_WORKFLOWS = new Set([ + ".github/workflows/ci.yml", + WORKFLOW_PATH, + ".github/workflows/publish.yaml", + ".github/workflows/bench.yml", +]); + +/** Every workflow run GitHub started for `sha`, across all workflows and events. */ +const listRuns = Effect.fn("CommitTrace.listRuns")(function* (sha: string) { + const github = yield* GitHub; + const runs: Array = []; + for (let page = 1; ; page++) { + const batch = yield* github.get( + `/repos/${github.repository}/actions/runs?head_sha=${sha}&per_page=100&page=${page}`, + Runs, + ); + runs.push(...batch.workflow_runs); + if (batch.workflow_runs.length < 100 || runs.length >= batch.total_count) + return runs; + } +}); + +/** + * Span attributes from a Pip-Boy run's reports, keyed by job name, so a + * job's span carries the numbers it measured. + */ +const reportAttributes = Effect.fn("CommitTrace.reportAttributes")(function* ( + runId: number, +) { + const github = yield* GitHub; + const fs = yield* FileSystem.FileSystem; + const byJob = new Map>(); + const add = (job: string, attributes: ReadonlyArray) => + byJob.set(job, [...(byJob.get(job) ?? []), ...attributes]); + + const { artifacts } = yield* github.get( + `/repos/${github.repository}/actions/runs/${runId}/artifacts?per_page=100`, + Artifacts, + ); + const reports = artifacts.filter( + (a) => a.name.startsWith("report-") && !a.expired, + ); + if (reports.length === 0) return byJob; + const dir = yield* fs.makeTempDirectoryScoped({ + prefix: "pipboy-trace-", + }); + yield* run("gh", [ + "run", + "download", + String(runId), + "--repo", + github.repository, + "--dir", + dir, + ...reports.flatMap((a) => ["--name", a.name]), + ]); + + const read = (path: string, schema: S) => + fs + .readFileString(path) + .pipe( + Effect.flatMap( + Schema.decodeUnknownEffect(Schema.fromJsonString(schema)), + ), + ); + for (const artifact of reports) { + const artifactDir = reports.length === 1 ? dir : join(dir, artifact.name); + for (const file of yield* fs.readDirectory(artifactDir)) { + const path = join(artifactDir, file); + if (file === "footprint.json") { + const comparison = compareFootprint(yield* read(path, FootprintReport)); + for (const row of comparison.rows) { + const pr = row.install[0]?.pr; + if (!pr) continue; + add("footprint", [ + attr(`rivet.footprint.install_bytes.${row.name}`, pr.bytes), + attr(`rivet.footprint.packages.${row.name}`, pr.count), + ]); + } + } else if (file === "hygiene.json") { + for (const row of compareHygiene(yield* read(path, HygieneReport)) + .rows) { + add("hygiene", [ + attr(`rivet.hygiene.${row.check}.new`, row.added.length), + attr(`rivet.hygiene.${row.check}.existing`, row.existing), + ]); + } + } else if (/^native-.+\.json$/.test(file)) { + const { artifact: a } = yield* read(path, NativeReport); + add(`native · ${a.target} · ${a.platform}`, [ + attr("rivet.native.bytes", a.bytes), + ]); + } + } + } + return byJob; +}); + +export class NoRunsForCommit extends Schema.TaggedError()( + "NoRunsForCommit", + { + sha: Schema.String, + repository: Schema.String, + }, +) { + override get message() { + return `no workflow runs found for ${this.sha} in ${this.repository}`; + } +} + +/** + * One trace for the CI that ran on a commit: commit, then each traced workflow + * run, its jobs, and their steps. Pip-Boy jobs also carry the sizes and + * findings they measured. + */ +export const commitTrace = Effect.fn("CommitTrace.build")(function* ( + sha: string, +) { + const github = yield* GitHub; + const now = Date.now(); + const traceId = traceIdFor(github.repository, sha); + // A push can start the same workflow twice for one commit; the newer run cancels the older. + // Only the newest run of each traced workflow is kept. + const newest = new Map(); + for (const r of yield* listRuns(sha)) { + if (!TRACED_WORKFLOWS.has(r.path)) continue; + const kept = newest.get(r.path); + if (kept === undefined || r.id > kept.id) newest.set(r.path, r); + } + const runs = [...newest.values()]; + if (runs.length === 0) + return yield* new NoRunsForCommit({ sha, repository: github.repository }); + + const perRun = yield* Effect.forEach( + runs, + (r) => + Effect.all({ + jobs: readJobs(r.id, r.run_attempt), + attributes: + r.path === WORKFLOW_PATH + ? reportAttributes(r.id) + : Effect.succeed(new Map>()), + }).pipe(Effect.map((details) => ({ run: r, ...details }))), + { concurrency: 8 }, + ); + + const rootId = runSpanId(`commit:${sha}`); + const runStart = (r: WorkflowRun) => + toMillis(r.run_started_at, toMillis(r.created_at, now)); + const runEnd = (r: WorkflowRun) => + r.status === "completed" ? toMillis(r.updated_at, now) : now; + const spans = [ + span({ + traceId, + id: rootId, + name: `commit ${sha.slice(0, 7)}`, + startMs: Math.min(...runs.map(runStart)), + endMs: Math.max(...runs.map(runEnd)), + failed: runs.some((r) => r.conclusion === "failure"), + attributes: [ + attr("vcs.ref.head.revision", sha), + attr("vcs.repository.name", github.repository), + attr("cicd.pipeline.run.count", runs.length), + ], + }), + ]; + for (const { run: r, jobs, attributes } of perRun) { + const id = runSpanId(r.id); + spans.push( + span({ + traceId, + id, + parent: rootId, + name: r.name ?? r.path, + startMs: runStart(r), + endMs: runEnd(r), + failed: r.conclusion === "failure", + attributes: [ + attr("cicd.pipeline.name", r.name ?? r.path), + attr("cicd.pipeline.run.id", r.id), + attr("cicd.pipeline.run.url.full", r.html_url), + attr("cicd.pipeline.result", r.conclusion ?? r.status ?? "unknown"), + attr("cicd.pipeline.trigger.event", r.event), + attr("cicd.pipeline.run.attempt", r.run_attempt), + ], + }), + ...jobSpans( + traceId, + id, + jobs, + now, + (job) => attributes.get(job.name) ?? [], + ), + ); + } + return { runs: runs.length, document: otlpDocument(spans) }; +}); diff --git a/scripts/pipboy/src/trace/spans.ts b/scripts/pipboy/src/trace/spans.ts new file mode 100644 index 0000000000..b57ee5bf12 --- /dev/null +++ b/scripts/pipboy/src/trace/spans.ts @@ -0,0 +1,163 @@ +import { createHash } from "node:crypto"; + +export interface Step { + readonly name: string; + readonly number: number; + readonly conclusion: string | null; + readonly started_at: string | null; + readonly completed_at: string | null; +} + +export interface Job { + readonly id: number; + readonly name: string; + readonly conclusion: string | null; + readonly created_at?: string; + readonly started_at: string | null; + readonly completed_at: string | null; + readonly steps?: ReadonlyArray; +} + +export interface Attribute { + readonly key: string; + readonly value: + | { readonly stringValue: string } + | { readonly intValue: string }; +} + +export interface Span { + readonly traceId: string; + readonly spanId: string; + readonly parentSpanId?: string; + readonly name: string; + readonly kind: 1; + readonly startTimeUnixNano: string; + readonly endTimeUnixNano: string; + readonly attributes: ReadonlyArray; + readonly status: { readonly code: 1 | 2 }; +} + +const hex = (input: string, length: number) => + createHash("sha256").update(input).digest("hex").slice(0, length); + +/** One trace per commit: every workflow run for the commit lands in it, from any tool. */ +export const traceIdFor = (repository: string, sha: string) => + hex(`${repository}:${sha}`, 32); +export const runSpanId = (runId: number | string) => hex(`run:${runId}`, 16); + +export const attr = (key: string, value: string | number): Attribute => + typeof value === "number" + ? { key, value: { intValue: String(Math.round(value)) } } + : { key, value: { stringValue: value } }; + +/** + * Short step names: `Run actions/checkout@` becomes `checkout`. Rendered + * as code in comments, so GitHub never turns `owner/repo@sha` into a commit + * link or `@name` into a mention. + */ +export function stepLabel(name: string): string { + const action = name.match( + /^(Post )?Run [\w.-]+\/([\w.-]+)(?:\/[\w./-]+)?@\S+$/, + ); + if (action) return `${action[1] ? "post " : ""}${action[2]}`; + return name.split("\n")[0] ?? name; +} + +export const toMillis = (iso: string | null | undefined, fallback: number) => + iso === null || iso === undefined ? fallback : Date.parse(iso); + +export function span(fields: { + traceId: string; + id: string; + parent?: string; + name: string; + startMs: number; + endMs: number; + failed?: boolean; + attributes?: ReadonlyArray; +}): Span { + const nanos = (ms: number) => `${BigInt(Math.round(ms)) * 1_000_000n}`; + return { + traceId: fields.traceId, + spanId: fields.id, + ...(fields.parent ? { parentSpanId: fields.parent } : {}), + name: fields.name, + kind: 1, + startTimeUnixNano: nanos(fields.startMs), + endTimeUnixNano: nanos(Math.max(fields.startMs, fields.endMs)), + attributes: fields.attributes ?? [], + status: { code: fields.failed ? 2 : 1 }, + }; +} + +/** Job and step spans under `parent`. Running jobs and steps end at `now`. */ +export function jobSpans( + traceId: string, + parent: string, + jobs: ReadonlyArray, + now: number, + extraAttributes: (job: Job) => ReadonlyArray = () => [], +): Array { + const spans: Array = []; + for (const job of jobs) { + if (job.started_at === null) continue; + const jobId = hex(`job:${job.id}`, 16); + const start = toMillis(job.started_at, now); + spans.push( + span({ + traceId, + id: jobId, + parent, + name: job.name, + startMs: start, + endMs: toMillis(job.completed_at, now), + failed: job.conclusion === "failure", + attributes: [ + attr("cicd.pipeline.task.name", job.name), + attr("cicd.pipeline.result", job.conclusion ?? "running"), + // Time spent waiting for a runner before the job started. + ...(job.created_at + ? [ + attr( + "cicd.pipeline.task.queued_ms", + start - toMillis(job.created_at, start), + ), + ] + : []), + ...extraAttributes(job), + ], + }), + ); + for (const step of job.steps ?? []) { + if (step.started_at === null) continue; + spans.push( + span({ + traceId, + id: hex(`step:${job.id}:${step.number}`, 16), + parent: jobId, + name: stepLabel(step.name), + startMs: toMillis(step.started_at, now), + endMs: toMillis(step.completed_at, now), + failed: step.conclusion === "failure", + attributes: [ + attr("cicd.pipeline.task.step.name", step.name), + attr("cicd.pipeline.result", step.conclusion ?? "running"), + ], + }), + ); + } + } + return spans; +} + +/** An OTLP/JSON trace export, as accepted by `POST /v1/traces`. */ +export function otlpDocument(spans: ReadonlyArray) { + return { + resourceSpans: [ + { + resource: { attributes: [attr("service.name", "rivet-ci")] }, + scopeSpans: [{ scope: { name: "pipboy" }, spans }], + }, + ], + }; +}