/** * Tracing. The platform opens a trace for a deployment or an action and * passes its context in the notification; the SDK continues it with the * `@nullplatform/tracing` producer SDK: one run per action, one step per * `ctx.step`, labels for the entity, the action and the package. Package * code never sees this. * * Off unless the agent passes NP_TRACING_URL (and the tracing SDK is * installed); the client is loaded lazily and never throws into a handler. * A package built before tracing keeps working: it is the SDK that reports. */ import { Tracer } from "@nullplatform/tracing"; import { cfg } from "../config"; import { accessToken } from "../auth"; import { silentNarrative, type Explanation, type Narrative } from "../scope/narrative"; export interface ActionSpan { /** A step of the run; `run` receives the step's narrator. */ step(name: string, run: (step: Narrative) => Promise): Promise; /** The run's own narrative. */ trace: Narrative; end( status: "success" | "failed", error?: { code: string; message: string } ): Promise; } export interface Tracing { action(notification: Record, meta: ActionMeta): ActionSpan; } /** One expected step of an action, declared ahead of the run; `group` names the stage it belongs to. */ export interface PlanStep { key: string; /** The step's name on screen (a handler's `explain.title` overrides it). */ title?: string; /** What the step does and why, as the console shows it under the step until the handler says more. */ description?: string; group?: string; } /** A stage of the plan: the console's deployment timeline shows one station per group. */ export interface PlanGroup { key: string; title?: string; } /** The plan of an action: its stages and its steps, each step in a stage, and the action's own name and intent. */ export interface Plan { /** The run's name on screen (a handler's `ctx.trace.explain({ title })` overrides it). */ title?: string; /** What the action does, shown as the run's narrative until the handler says more. */ description?: string; groups?: PlanGroup[]; steps: PlanStep[]; } export interface ActionMeta { kind: string; action: string; packageSlug: string; packageVersion: string; /** The run's plan — the steps the action is expected to run, grouped into stages — which the console's timeline needs to tell the story. */ plan?: Plan; } /** What a package declares for an action: its steps (flat), or stages and steps, or a function of the notification that names them. */ export type PlanSteps = Array; export type PlanSpec = { title?: string; description?: string; groups?: PlanGroup[]; steps: PlanSteps }; export type PlanDeclaration = PlanSteps | PlanSpec | ((notification: Record) => PlanSteps | PlanSpec); /** The plan a package declared for `action`, resolved against the notification; none when it declared nothing. */ export function planFor( plans: Record | undefined, action: string, notification: Record ): Plan | undefined { const declared = plans?.[action]; if (!declared) return undefined; const resolved = typeof declared === "function" ? declared(notification) : declared; const spec: PlanSpec = Array.isArray(resolved) ? { steps: resolved } : resolved; const steps = spec.steps.map((s) => (typeof s === "string" ? { key: s } : s)).filter((s) => s.key); if (!steps.length) return undefined; // A plan with no stages gets one per step: the trace view shows stages only. const staged = !!spec.groups?.length; return { ...(spec.title ? { title: spec.title } : {}), ...(spec.description ? { description: spec.description } : {}), groups: staged ? spec.groups : steps.map((s) => ({ key: s.key, title: s.title ?? humanize(s.key) })), steps: staged ? steps : steps.map((s) => ({ ...s, group: s.group ?? s.key })), }; } export interface TraceContext { traceId: string; parentRunId?: string; } export const noopTracing: Tracing = { action: () => ({ step: (_n, run) => run(silentNarrative), trace: silentNarrative, end: async () => {} }), }; /** Parses the notification's trace: the event block's `trace`, or the legacy `1||`. */ export function traceContextOf( n: Record ): TraceContext | undefined { const event = n.event as | { trace?: { trace_id?: string; parent_run_id?: string } } | undefined; if (event?.trace?.trace_id) return { traceId: event.trace.trace_id, parentRunId: event.trace.parent_run_id, }; const raw = n.trace_context; if (typeof raw === "string" && raw.includes("|")) { const [, traceId, parent] = raw.split("|"); if (traceId) return { traceId, parentRunId: parent || undefined }; } return undefined; } // ─── The producer SDK, as this module uses it ──────────────────────── // Structural types so the tracing SDK stays an optional dependency: a real // Tracer satisfies them, and a test can hand in a fake. export interface TracingHandle { labels(values: Record): this; complete(): void; fail(err: unknown): void; /** `tracing.explain` — the narrative the console leads with; `title` is the node's name. */ explain?(body: Explanation & { title: string }): unknown; /** `tracing.external_links`. */ externalLinks?(body: { rel: string; uri: string; label?: string }): unknown; /** Inline `tracing.output`. */ output?(name: string, value: unknown): unknown; } /** A reference to a run in another process: what the tracing API links edges to. `type` is required on the wire. */ export interface RunReference { type: "run"; trace_id: string; run_id: string; } export interface TracingRunHandle extends TracingHandle { step(spec: { key: string } | string): TracingHandle; /** The expected step DAG (`tracing.plan`); progress is the diff against the steps observed. */ plan?(body: { groups?: Array<{ key: string; title?: string }>; steps: Array<{ key: string; title?: string; description?: string; after?: string; group?: string }>; }): unknown; /** The platform links its runs with `triggered_by` edges, child to parent; the console's graph walks those. */ triggeredBy?(upstream: RunReference): void; continues?(upstream: RunReference): void; } export interface TracingClient { run(spec: { trace_id: string; run_id: string }): TracingRunHandle; flush?(): Promise; } /** * What `definePackage({ tracing })` accepts: the client above, or the real * `@nullplatform/tracing` Tracer as it is — its `run` has callback and handle * overloads the narrower interface cannot name, and the SDK only ever calls * the handle form. */ export type TracingClientLike = | TracingClient | { run: (...args: any[]) => unknown; flush?: () => Promise }; const sanitize = (s: string) => s.replace(/[^A-Za-z0-9._:-]+/g, "-"); const text = (v: unknown): string | undefined => typeof v === "string" && v.trim() ? v.trim() : undefined; const idOf = (v: unknown): string | undefined => { const id = (v as { id?: unknown } | null | undefined)?.id; return id === undefined || id === null || id === "" ? undefined : String(id); }; /** * The run's NRN: the most specific entity the notification carries, the way * `np service workflow exec` scopes its runs from the build context (scope, * then the link's, the service's, the one the notification names). Empty * means the API derives it from the caller's token. */ export function nrnOf(n: Record): string | undefined { const scope = n.scope as { nrn?: unknown } | null | undefined; const link = n.link as { nrn?: unknown } | null | undefined; const service = n.service as { nrn?: unknown; entity_nrn?: unknown } | null | undefined; return ( text(scope?.nrn) ?? text(link?.nrn) ?? text(service?.entity_nrn) ?? text(service?.nrn) ?? text(n.entity_nrn) ?? text(n.nrn) ); } /** `.id` for every entity the notification resolved — ids only, never attributes or parameters. */ export function entityLabels(n: Record): Record { const out: Record = {}; for (const entity of ["scope", "application", "namespace", "account", "deployment", "release", "service", "link"]) { out[`${entity}.id`] = idOf(n[entity]); } // A scope action names its scope and deployment in the parameters, not as objects. const p = (n.parameters ?? {}) as Record; const id = (v: unknown) => (typeof v === "number" ? String(v) : text(v)); out["scope.id"] ??= id(p.scope_id); out["deployment.id"] ??= id(p.deployment_id); return out; } /** Tracing over a producer client: a run per action, a step per ctx.step. */ export function tracingWith(clientLike: TracingClientLike): Tracing { const client = clientLike as TracingClient; observeDrops(clientLike); return { action(n, meta) { const actionId = String(n.id ?? ""); const ctx = traceContextOf(n); const runId = `action-${actionId || Date.now()}`; // The run is scoped to the entity's NRN. The API derives a tenancy from // the caller's token when a run names none, but an agent's key names no // resource and every event comes back 403; the notification always // says which entity the action is about. const nrn = nrnOf(n); const run = client.run({ trace_id: ctx?.traceId ?? runId, run_id: runId, ...(nrn ? { nrn } : {}), }); if (ctx?.parentRunId && ctx.parentRunId !== runId) { // The run the platform opened for this action (the deployment's or the // scope's provisioning run) triggered this one: the same edge the // platform draws between its own runs, so the console nests the action // and its steps under the deployment. The reference carries its // `type`; without it the API rejects the edge. const upstream: RunReference = { type: "run", trace_id: ctx.traceId, run_id: ctx.parentRunId }; if (run.triggeredBy) run.triggeredBy(upstream); else run.continues?.(upstream); // The console's deployment timeline folds a provisioning run with its // DESCENDANTS through `parent` edges (triggered_by only names the // cause), the edge the Go SDK draws when the CLI opens a workflow as // the upstream's child. The JS producer SDK cannot open a child of a // run it does not own, so the edge is posted directly, once the run's // own events are out (an edge to a node the API has not seen yet is // refused). Best effort: tracing never fails an action. void nestUnder(client, upstream, { type: "run", trace_id: ctx.traceId, run_id: runId }, nrn); } const service = n.service as { slug?: unknown } | null | undefined; // The plan the package declared for this action: without one the // console's deployment timeline has nothing to tell and keeps the plain // entity page, however many steps the run then reports. Each step // follows the previous, the way a workflow's steps do. // The plan's titles name the steps on screen; a step's narrator falls // back to them when the handler explains without a title. const titles = new Map(); if (meta.plan?.steps.length && run.plan) { const { groups, steps } = meta.plan; const keys = steps.map((s) => sanitize(s.key)); steps.forEach((s, i) => s.title && titles.set(keys[i], s.title)); run.plan({ ...(groups?.length ? { groups: groups.map((g) => ({ key: sanitize(g.key), ...(g.title ? { title: g.title } : {}) })) } : {}), steps: steps.map((s, i) => ({ key: keys[i], ...(s.title ? { title: s.title } : {}), ...(s.description ? { description: s.description } : {}), ...(i > 0 ? { after: keys[i - 1] } : {}), ...(s.group ? { group: sanitize(s.group) } : {}), })), }); } const actionTitle = meta.plan?.title ?? humanize(meta.action); const trace = narrate(run, () => actionTitle); // The run reads as the action's intent until the handler says what happened. if (meta.plan?.title || meta.plan?.description) { trace.explain({ title: actionTitle, ...(meta.plan.description ? { what: meta.plan.description } : {}) }); } run.labels({ entity: meta.kind, action: meta.action, "package.slug": meta.packageSlug, "package.version": meta.packageVersion, ...entityLabels(n), "service.slug": text(service?.slug), "action.id": actionId || undefined, }); return { trace, async step(name, fn) { const key = sanitize(name); const step = run.step({ key }); const narrator = narrate(step, () => titles.get(key) ?? humanize(name)); try { const out = await fn(narrator); step.complete(); return out; } catch (err) { step.fail(err); throw err; } }, async end(status, error) { if (status === "success") run.complete(); else { // The run's narrative turns red and says what went wrong, unless // the handler already told the failure itself. if (trace.told?.severity !== "error") { trace.explain({ ...(trace.told ?? {}), severity: "error", what: error?.message ?? "failed" }); } run.fail( Object.assign(new Error(error?.message ?? "failed"), { code: error?.code, }) ); } await client.flush?.().catch(() => {}); }, }; }, }; } /** A narrator over a tracing handle: explain (titled, the default when the handler names none), link, output — never throwing. */ function narrate(handle: TracingHandle, defaultTitle: () => string): Narrative & { told?: Explanation } { const n: Narrative & { told?: Explanation } = { explain(body) { const told = { ...body, title: body.title ?? n.told?.title ?? defaultTitle() }; n.told = told; try { handle.explain?.(told); } catch { // a narrative never fails an action } }, link(link) { try { handle.externalLinks?.(link); } catch { // as above } }, output(name, value) { try { handle.output?.(name, value); } catch { // as above } }, }; return n; } /** `start-initial` → `Start initial`: a readable default for a node the plan did not title. */ function humanize(key: string): string { const words = key.replace(/[-_.]+/g, " ").trim().toLowerCase(); return words ? words.charAt(0).toUpperCase() + words.slice(1) : key; } /** * Where traces go: NP_TRACING_URL when set, else the platform API's own * tracing endpoint. Off in dev and test mode, and without an API key. */ export function tracingUrl(): string | undefined { const explicit = cfg.tracingUrl(); if (explicit) return explicit; if (cfg.mode() || !cfg.apiKey()) return undefined; return `${cfg.apiUrl().replace(/\/+$/, "")}/tracing`; } /** The tracing for this process, from the environment; nothing to configure. */ export function createTracing(producer = "@nullplatform/plugin"): Tracing { const baseURL = tracingUrl(); if (!baseURL) return noopTracing; try { return tracingWith(new Tracer({ baseURL, apiKey: cfg.apiKey(), producer }) as unknown as TracingClient); } catch { return noopTracing; } } /** The tracer drops what the API rejects; the first few reasons go to stderr so a missing trace is explainable. */ function observeDrops(client: unknown): void { const on = (client as { on?: (event: string, fn: (...a: unknown[]) => void) => unknown }).on; if (typeof on !== "function") return; let seen = 0; try { on.call(client, "drop", (envelope: unknown, reason: unknown) => { if (seen++ >= 3) return; const type = (envelope as { type?: unknown } | undefined)?.type; const why = reason instanceof Error ? reason.message : String(reason); process.stderr.write(`tracing: ${String(type ?? "event")} dropped: ${why}\n`); }); } catch { // a client without listeners traces as before } } /** The `edge.parent` event from the platform's run to this one, as the tracing API ingests it. */ export function parentEdgeEvent( upstream: RunReference, child: RunReference, nrn: string | undefined, producer: string ): Record { return { id: uuidV7(), time: new Date().toISOString(), type: "edge.parent", producer, ...(nrn ? { nrn } : {}), data: { from: upstream, to: child }, }; } async function nestUnder( client: TracingClient, upstream: RunReference, child: RunReference, nrn: string | undefined ): Promise { const base = cfg.tracingUrl(); if (!base) return; try { await client.flush?.(); const event = parentEdgeEvent(upstream, child, nrn, "@nullplatform/plugin"); const res = await fetch(`${base.replace(/\/+$/, "")}/events`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${await accessToken()}` }, body: JSON.stringify(event), }); if (!res.ok) process.stderr.write(`tracing: edge.parent to ${upstream.run_id} refused: HTTP ${res.status}\n`); } catch (err) { process.stderr.write(`tracing: edge.parent not posted: ${err instanceof Error ? err.message : String(err)}\n`); } } /** A UUIDv7 (time-ordered), the id every tracing event carries. */ export function uuidV7(): string { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); const ms = BigInt(Date.now()); for (let i = 0; i < 6; i++) bytes[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn); bytes[6] = (bytes[6] & 0x0f) | 0x70; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; }