|
| 1 | +// Anonymized discovery-plane telemetry event schema (pure) — #4301, Wave 2 tracker #2353 Phase 6. |
| 2 | +// |
| 3 | +// Typed event shapes for the OPTIONAL hosted discovery-index service (#4250) — which candidates a miner |
| 4 | +// fetched/ranked and whether a soft-claim succeeded or collided — so that shared service can be operated and |
| 5 | +// debugged WITHOUT ever holding source, diffs, or credentials. This mirrors governor-ledger.ts's pure |
| 6 | +// validate/normalize shape (fixed fail-closed vocabulary, JSON-round-trip-verified payload) and copies the |
| 7 | +// anonymization POSTURE of src/selfhost/orb-collector.ts (the one shipped precedent for "anonymized telemetry |
| 8 | +// leaving an instance"): repo/issue identifiers are HMAC hashes keyed by a per-instance secret the collector |
| 9 | +// never holds, and free-text-adjacent fields are collapsed to a fixed low-cardinality bucket rather than raw text. |
| 10 | +// |
| 11 | +// NEVER INCLUDED in a telemetry event (the discovery-plane analogue of orb-collector.ts:1-18's inventory): no |
| 12 | +// source contents, no diffs, no GitHub tokens or credentials, no full issue bodies or titles, no commit SHAs, and |
| 13 | +// no RAW repo/issue identifiers — only the exporter's per-instance HMAC hashes reach this shape. This module is |
| 14 | +// SCHEMA/TYPES ONLY: it does not export events, hash anything itself (the exporter does that at #4250's boundary), |
| 15 | +// or wire into an endpoint. It only defines and validates the on-the-wire contract. |
| 16 | + |
| 17 | +/** Immutable discovery-plane telemetry event vocabulary — an unknown value fails closed before it is recorded. */ |
| 18 | +export const MINER_TELEMETRY_EVENT_TYPES = Object.freeze([ |
| 19 | + "query_issued", |
| 20 | + "candidates_returned", |
| 21 | + "soft_claim_attempted", |
| 22 | + "soft_claim_succeeded", |
| 23 | + "soft_claim_collided", |
| 24 | +] as const); |
| 25 | + |
| 26 | +export type MinerTelemetryEventType = (typeof MINER_TELEMETRY_EVENT_TYPES)[number]; |
| 27 | + |
| 28 | +/** Fixed low-cardinality outcome buckets — the discovery-plane analogue of orb-collector's `bucketReasonCode`, so a |
| 29 | + * free-text reason can never leak through the telemetry surface. */ |
| 30 | +export const MINER_TELEMETRY_OUTCOME_BUCKETS = Object.freeze([ |
| 31 | + "ok", |
| 32 | + "empty", |
| 33 | + "collision", |
| 34 | + "rate_limited", |
| 35 | + "error", |
| 36 | + "other", |
| 37 | +] as const); |
| 38 | + |
| 39 | +export type MinerTelemetryOutcomeBucket = (typeof MINER_TELEMETRY_OUTCOME_BUCKETS)[number]; |
| 40 | + |
| 41 | +/** A single discovery-plane telemetry event, pre-anonymization-checked. `repoHash`/`issueHash` are the exporter's |
| 42 | + * per-instance HMAC hashes (orb-collector's `getOrCreateAnonSecret`/`hmacField` posture) — NEVER a raw |
| 43 | + * `owner/repo` or issue number. `metrics` is count-only quantitative data (e.g. `candidatesReturned`), never text. */ |
| 44 | +export type MinerTelemetryEvent = { |
| 45 | + eventType: MinerTelemetryEventType; |
| 46 | + repoHash?: string | null | undefined; |
| 47 | + issueHash?: string | null | undefined; |
| 48 | + outcome: MinerTelemetryOutcomeBucket; |
| 49 | + metrics?: Record<string, number> | undefined; |
| 50 | +}; |
| 51 | + |
| 52 | +/** The normalized, storage/transport-ready form: hashes coerced to `string | null`, metrics serialized to JSON. */ |
| 53 | +export type NormalizedMinerTelemetryEvent = { |
| 54 | + eventType: MinerTelemetryEventType; |
| 55 | + repoHash: string | null; |
| 56 | + issueHash: string | null; |
| 57 | + outcome: MinerTelemetryOutcomeBucket; |
| 58 | + metricsJson: string; |
| 59 | +}; |
| 60 | + |
| 61 | +const telemetryEventTypeSet = new Set<string>(MINER_TELEMETRY_EVENT_TYPES); |
| 62 | +const telemetryOutcomeSet = new Set<string>(MINER_TELEMETRY_OUTCOME_BUCKETS); |
| 63 | + |
| 64 | +/** Coerce an optional anonymized identifier. Present values must be a non-empty opaque hash — an anti-leak guard |
| 65 | + * rejects anything that looks like a RAW identifier (contains `/`, i.e. `owner/repo`, or any whitespace), so a |
| 66 | + * caller cannot accidentally ship an un-hashed `repoFullName` through the anonymized surface. */ |
| 67 | +function normalizeOptionalHash(value: unknown, code: string): string | null { |
| 68 | + if (value === undefined || value === null) return null; |
| 69 | + if (typeof value !== "string") throw new Error(code); |
| 70 | + const trimmed = value.trim(); |
| 71 | + if (!trimmed || trimmed.includes("/") || /\s/.test(trimmed)) throw new Error(code); |
| 72 | + return trimmed; |
| 73 | +} |
| 74 | + |
| 75 | +/** Serialize the count-only metrics map, rejecting any non-finite-number value (so no free text or NaN/Infinity can |
| 76 | + * slip into the telemetry payload). Absent metrics normalize to an empty object. */ |
| 77 | +function normalizeMetrics(metrics: unknown): string { |
| 78 | + if (metrics === undefined) return "{}"; |
| 79 | + if (metrics === null || typeof metrics !== "object" || Array.isArray(metrics)) throw new Error("invalid_metrics"); |
| 80 | + for (const value of Object.values(metrics as Record<string, unknown>)) { |
| 81 | + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("invalid_metrics"); |
| 82 | + } |
| 83 | + return JSON.stringify(metrics); |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * Validate and normalize a discovery-plane telemetry event before it is recorded/exported. Fail-closed, mirroring |
| 88 | + * {@link normalizeGovernorLedgerEvent}: an unknown `eventType`/`outcome`, a non-hash identifier, or a non-numeric |
| 89 | + * metric throws rather than silently shipping malformed or de-anonymizing data. Defines the contract only — it does |
| 90 | + * NOT perform the HMAC hashing (that is the exporter's job at #4250's boundary) or send anything. |
| 91 | + */ |
| 92 | +export function normalizeMinerTelemetryEvent(input: unknown): NormalizedMinerTelemetryEvent { |
| 93 | + if (!input || typeof input !== "object") throw new Error("invalid_event"); |
| 94 | + const event = input as Partial<MinerTelemetryEvent>; |
| 95 | + const eventType = typeof event.eventType === "string" ? event.eventType.trim() : ""; |
| 96 | + if (!telemetryEventTypeSet.has(eventType)) throw new Error("invalid_event_type"); |
| 97 | + const outcome = typeof event.outcome === "string" ? event.outcome.trim() : ""; |
| 98 | + if (!telemetryOutcomeSet.has(outcome)) throw new Error("invalid_outcome"); |
| 99 | + return { |
| 100 | + eventType: eventType as MinerTelemetryEventType, |
| 101 | + repoHash: normalizeOptionalHash(event.repoHash, "invalid_repo_hash"), |
| 102 | + issueHash: normalizeOptionalHash(event.issueHash, "invalid_issue_hash"), |
| 103 | + outcome: outcome as MinerTelemetryOutcomeBucket, |
| 104 | + metricsJson: normalizeMetrics(event.metrics), |
| 105 | + }; |
| 106 | +} |
0 commit comments