diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 2b8e1a90ae..d715ff189d 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -137,6 +137,15 @@ export { type GovernorLedgerEventType, type NormalizedGovernorLedgerEvent, } from "./governor-ledger.js"; +export { + MINER_TELEMETRY_EVENT_TYPES, + MINER_TELEMETRY_OUTCOME_BUCKETS, + normalizeMinerTelemetryEvent, + type MinerTelemetryEvent, + type MinerTelemetryEventType, + type MinerTelemetryOutcomeBucket, + type NormalizedMinerTelemetryEvent, +} from "./miner-telemetry.js"; export { ATTEMPT_LOG_EVENT_TYPES, createAttemptLogBuffer, diff --git a/packages/gittensory-engine/src/miner-telemetry.ts b/packages/gittensory-engine/src/miner-telemetry.ts new file mode 100644 index 0000000000..c0289e9f63 --- /dev/null +++ b/packages/gittensory-engine/src/miner-telemetry.ts @@ -0,0 +1,106 @@ +// Anonymized discovery-plane telemetry event schema (pure) — #4301, Wave 2 tracker #2353 Phase 6. +// +// Typed event shapes for the OPTIONAL hosted discovery-index service (#4250) — which candidates a miner +// fetched/ranked and whether a soft-claim succeeded or collided — so that shared service can be operated and +// debugged WITHOUT ever holding source, diffs, or credentials. This mirrors governor-ledger.ts's pure +// validate/normalize shape (fixed fail-closed vocabulary, JSON-round-trip-verified payload) and copies the +// anonymization POSTURE of src/selfhost/orb-collector.ts (the one shipped precedent for "anonymized telemetry +// leaving an instance"): repo/issue identifiers are HMAC hashes keyed by a per-instance secret the collector +// never holds, and free-text-adjacent fields are collapsed to a fixed low-cardinality bucket rather than raw text. +// +// NEVER INCLUDED in a telemetry event (the discovery-plane analogue of orb-collector.ts:1-18's inventory): no +// source contents, no diffs, no GitHub tokens or credentials, no full issue bodies or titles, no commit SHAs, and +// no RAW repo/issue identifiers — only the exporter's per-instance HMAC hashes reach this shape. This module is +// SCHEMA/TYPES ONLY: it does not export events, hash anything itself (the exporter does that at #4250's boundary), +// or wire into an endpoint. It only defines and validates the on-the-wire contract. + +/** Immutable discovery-plane telemetry event vocabulary — an unknown value fails closed before it is recorded. */ +export const MINER_TELEMETRY_EVENT_TYPES = Object.freeze([ + "query_issued", + "candidates_returned", + "soft_claim_attempted", + "soft_claim_succeeded", + "soft_claim_collided", +] as const); + +export type MinerTelemetryEventType = (typeof MINER_TELEMETRY_EVENT_TYPES)[number]; + +/** Fixed low-cardinality outcome buckets — the discovery-plane analogue of orb-collector's `bucketReasonCode`, so a + * free-text reason can never leak through the telemetry surface. */ +export const MINER_TELEMETRY_OUTCOME_BUCKETS = Object.freeze([ + "ok", + "empty", + "collision", + "rate_limited", + "error", + "other", +] as const); + +export type MinerTelemetryOutcomeBucket = (typeof MINER_TELEMETRY_OUTCOME_BUCKETS)[number]; + +/** A single discovery-plane telemetry event, pre-anonymization-checked. `repoHash`/`issueHash` are the exporter's + * per-instance HMAC hashes (orb-collector's `getOrCreateAnonSecret`/`hmacField` posture) — NEVER a raw + * `owner/repo` or issue number. `metrics` is count-only quantitative data (e.g. `candidatesReturned`), never text. */ +export type MinerTelemetryEvent = { + eventType: MinerTelemetryEventType; + repoHash?: string | null | undefined; + issueHash?: string | null | undefined; + outcome: MinerTelemetryOutcomeBucket; + metrics?: Record | undefined; +}; + +/** The normalized, storage/transport-ready form: hashes coerced to `string | null`, metrics serialized to JSON. */ +export type NormalizedMinerTelemetryEvent = { + eventType: MinerTelemetryEventType; + repoHash: string | null; + issueHash: string | null; + outcome: MinerTelemetryOutcomeBucket; + metricsJson: string; +}; + +const telemetryEventTypeSet = new Set(MINER_TELEMETRY_EVENT_TYPES); +const telemetryOutcomeSet = new Set(MINER_TELEMETRY_OUTCOME_BUCKETS); + +/** Coerce an optional anonymized identifier. Present values must be a non-empty opaque hash — an anti-leak guard + * rejects anything that looks like a RAW identifier (contains `/`, i.e. `owner/repo`, or any whitespace), so a + * caller cannot accidentally ship an un-hashed `repoFullName` through the anonymized surface. */ +function normalizeOptionalHash(value: unknown, code: string): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") throw new Error(code); + const trimmed = value.trim(); + if (!trimmed || trimmed.includes("/") || /\s/.test(trimmed)) throw new Error(code); + return trimmed; +} + +/** Serialize the count-only metrics map, rejecting any non-finite-number value (so no free text or NaN/Infinity can + * slip into the telemetry payload). Absent metrics normalize to an empty object. */ +function normalizeMetrics(metrics: unknown): string { + if (metrics === undefined) return "{}"; + if (metrics === null || typeof metrics !== "object" || Array.isArray(metrics)) throw new Error("invalid_metrics"); + for (const value of Object.values(metrics as Record)) { + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("invalid_metrics"); + } + return JSON.stringify(metrics); +} + +/** + * Validate and normalize a discovery-plane telemetry event before it is recorded/exported. Fail-closed, mirroring + * {@link normalizeGovernorLedgerEvent}: an unknown `eventType`/`outcome`, a non-hash identifier, or a non-numeric + * metric throws rather than silently shipping malformed or de-anonymizing data. Defines the contract only — it does + * NOT perform the HMAC hashing (that is the exporter's job at #4250's boundary) or send anything. + */ +export function normalizeMinerTelemetryEvent(input: unknown): NormalizedMinerTelemetryEvent { + if (!input || typeof input !== "object") throw new Error("invalid_event"); + const event = input as Partial; + const eventType = typeof event.eventType === "string" ? event.eventType.trim() : ""; + if (!telemetryEventTypeSet.has(eventType)) throw new Error("invalid_event_type"); + const outcome = typeof event.outcome === "string" ? event.outcome.trim() : ""; + if (!telemetryOutcomeSet.has(outcome)) throw new Error("invalid_outcome"); + return { + eventType: eventType as MinerTelemetryEventType, + repoHash: normalizeOptionalHash(event.repoHash, "invalid_repo_hash"), + issueHash: normalizeOptionalHash(event.issueHash, "invalid_issue_hash"), + outcome: outcome as MinerTelemetryOutcomeBucket, + metricsJson: normalizeMetrics(event.metrics), + }; +} diff --git a/test/unit/miner-telemetry.test.ts b/test/unit/miner-telemetry.test.ts new file mode 100644 index 0000000000..814c25093a --- /dev/null +++ b/test/unit/miner-telemetry.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + MINER_TELEMETRY_EVENT_TYPES, + MINER_TELEMETRY_OUTCOME_BUCKETS, + normalizeMinerTelemetryEvent, + type MinerTelemetryEvent, +} from "../../packages/gittensory-engine/src/miner-telemetry"; + +const base: MinerTelemetryEvent = { eventType: "candidates_returned", outcome: "ok" }; + +describe("miner-telemetry schema (#4301)", () => { + it("freezes fixed, low-cardinality vocabularies", () => { + expect(Object.isFrozen(MINER_TELEMETRY_EVENT_TYPES)).toBe(true); + expect(Object.isFrozen(MINER_TELEMETRY_OUTCOME_BUCKETS)).toBe(true); + expect([...MINER_TELEMETRY_EVENT_TYPES]).toContain("soft_claim_collided"); + expect([...MINER_TELEMETRY_OUTCOME_BUCKETS]).toContain("collision"); + }); + + it("normalizes a full event: hashed identifiers pass through, metrics serialize to JSON", () => { + expect( + normalizeMinerTelemetryEvent({ + eventType: "candidates_returned", + repoHash: " a1b2c3 ", + issueHash: "deadbeef", + outcome: "ok", + metrics: { candidatesReturned: 12, rankMs: 3 }, + }), + ).toEqual({ + eventType: "candidates_returned", + repoHash: "a1b2c3", + issueHash: "deadbeef", + outcome: "ok", + metricsJson: '{"candidatesReturned":12,"rankMs":3}', + }); + }); + + it("defaults absent identifiers to null and absent metrics to an empty object", () => { + expect(normalizeMinerTelemetryEvent({ eventType: "query_issued", outcome: "empty" })).toEqual({ + eventType: "query_issued", + repoHash: null, + issueHash: null, + outcome: "empty", + metricsJson: "{}", + }); + // explicit null identifiers are also accepted + expect(normalizeMinerTelemetryEvent({ ...base, repoHash: null, issueHash: null }).repoHash).toBeNull(); + }); + + it("fails closed on a non-object, an unknown event type, or an unknown outcome bucket", () => { + expect(() => normalizeMinerTelemetryEvent(null)).toThrow("invalid_event"); + expect(() => normalizeMinerTelemetryEvent("nope")).toThrow("invalid_event"); + expect(() => normalizeMinerTelemetryEvent({ ...base, eventType: "mystery" })).toThrow("invalid_event_type"); + expect(() => normalizeMinerTelemetryEvent({ eventType: 7, outcome: "ok" })).toThrow("invalid_event_type"); + expect(() => normalizeMinerTelemetryEvent({ ...base, outcome: "great" })).toThrow("invalid_outcome"); + expect(() => normalizeMinerTelemetryEvent({ eventType: "query_issued", outcome: 5 })).toThrow("invalid_outcome"); + }); + + it("anti-leak guard: rejects a non-hash identifier (raw owner/repo, whitespace, non-string, or empty)", () => { + expect(() => normalizeMinerTelemetryEvent({ ...base, repoHash: "acme/widgets" })).toThrow("invalid_repo_hash"); + expect(() => normalizeMinerTelemetryEvent({ ...base, repoHash: "has space" })).toThrow("invalid_repo_hash"); + expect(() => normalizeMinerTelemetryEvent({ ...base, repoHash: " " })).toThrow("invalid_repo_hash"); + expect(() => normalizeMinerTelemetryEvent({ ...base, repoHash: 123 })).toThrow("invalid_repo_hash"); + expect(() => normalizeMinerTelemetryEvent({ ...base, issueHash: "owner/12" })).toThrow("invalid_issue_hash"); + }); + + it("rejects non-numeric or malformed metrics (no free text or NaN can leak through)", () => { + expect(() => normalizeMinerTelemetryEvent({ ...base, metrics: { n: "twelve" } })).toThrow("invalid_metrics"); + expect(() => normalizeMinerTelemetryEvent({ ...base, metrics: { n: Number.NaN } })).toThrow("invalid_metrics"); + expect(() => normalizeMinerTelemetryEvent({ ...base, metrics: [1, 2] })).toThrow("invalid_metrics"); + expect(() => normalizeMinerTelemetryEvent({ ...base, metrics: null })).toThrow("invalid_metrics"); + }); + + it("is re-exported from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.normalizeMinerTelemetryEvent).toBe("function"); + expect(barrel.MINER_TELEMETRY_EVENT_TYPES).toBe(MINER_TELEMETRY_EVENT_TYPES); + }); +});