Skip to content

Commit 4daceaa

Browse files
feat(miner-discovery-plane): add anonymized telemetry event schema for the hosted plane (#4301) (#4438)
Add packages/gittensory-engine/src/miner-telemetry.ts: the typed, pure schema/validator for the optional hosted discovery-index service's telemetry (query issued, candidates returned, soft-claim attempted/succeeded/collided) so that shared service can be operated and debugged without ever holding source, diffs, or credentials. Mirrors governor-ledger.ts's fail-closed normalize shape (fixed event-type + outcome-bucket vocabularies, unknown values throw) and copies orb-collector.ts's anonymization posture: repo/issue identifiers are the exporter's per-instance HMAC hashes (never raw owner/repo), free-text-adjacent fields collapse to a fixed low-cardinality outcome bucket, and metrics are count-only. An anti-leak guard in normalizeMinerTelemetryEvent rejects a repoHash/issueHash that looks like a raw identifier (contains '/' or whitespace), and metrics reject any non-finite-number value, so no de-anonymizing or free-text data can slip onto the wire. Schema/types only, per the issue: no exporter, no HMAC execution (the exporter does that at #4250's boundary), no endpoint wiring. Re-exported from the barrel. Closes #4301
1 parent a51dadb commit 4daceaa

3 files changed

Lines changed: 193 additions & 0 deletions

File tree

packages/gittensory-engine/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,15 @@ export {
137137
type GovernorLedgerEventType,
138138
type NormalizedGovernorLedgerEvent,
139139
} from "./governor-ledger.js";
140+
export {
141+
MINER_TELEMETRY_EVENT_TYPES,
142+
MINER_TELEMETRY_OUTCOME_BUCKETS,
143+
normalizeMinerTelemetryEvent,
144+
type MinerTelemetryEvent,
145+
type MinerTelemetryEventType,
146+
type MinerTelemetryOutcomeBucket,
147+
type NormalizedMinerTelemetryEvent,
148+
} from "./miner-telemetry.js";
140149
export {
141150
ATTEMPT_LOG_EVENT_TYPES,
142151
createAttemptLogBuffer,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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+
}

test/unit/miner-telemetry.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
MINER_TELEMETRY_EVENT_TYPES,
4+
MINER_TELEMETRY_OUTCOME_BUCKETS,
5+
normalizeMinerTelemetryEvent,
6+
type MinerTelemetryEvent,
7+
} from "../../packages/gittensory-engine/src/miner-telemetry";
8+
9+
const base: MinerTelemetryEvent = { eventType: "candidates_returned", outcome: "ok" };
10+
11+
describe("miner-telemetry schema (#4301)", () => {
12+
it("freezes fixed, low-cardinality vocabularies", () => {
13+
expect(Object.isFrozen(MINER_TELEMETRY_EVENT_TYPES)).toBe(true);
14+
expect(Object.isFrozen(MINER_TELEMETRY_OUTCOME_BUCKETS)).toBe(true);
15+
expect([...MINER_TELEMETRY_EVENT_TYPES]).toContain("soft_claim_collided");
16+
expect([...MINER_TELEMETRY_OUTCOME_BUCKETS]).toContain("collision");
17+
});
18+
19+
it("normalizes a full event: hashed identifiers pass through, metrics serialize to JSON", () => {
20+
expect(
21+
normalizeMinerTelemetryEvent({
22+
eventType: "candidates_returned",
23+
repoHash: " a1b2c3 ",
24+
issueHash: "deadbeef",
25+
outcome: "ok",
26+
metrics: { candidatesReturned: 12, rankMs: 3 },
27+
}),
28+
).toEqual({
29+
eventType: "candidates_returned",
30+
repoHash: "a1b2c3",
31+
issueHash: "deadbeef",
32+
outcome: "ok",
33+
metricsJson: '{"candidatesReturned":12,"rankMs":3}',
34+
});
35+
});
36+
37+
it("defaults absent identifiers to null and absent metrics to an empty object", () => {
38+
expect(normalizeMinerTelemetryEvent({ eventType: "query_issued", outcome: "empty" })).toEqual({
39+
eventType: "query_issued",
40+
repoHash: null,
41+
issueHash: null,
42+
outcome: "empty",
43+
metricsJson: "{}",
44+
});
45+
// explicit null identifiers are also accepted
46+
expect(normalizeMinerTelemetryEvent({ ...base, repoHash: null, issueHash: null }).repoHash).toBeNull();
47+
});
48+
49+
it("fails closed on a non-object, an unknown event type, or an unknown outcome bucket", () => {
50+
expect(() => normalizeMinerTelemetryEvent(null)).toThrow("invalid_event");
51+
expect(() => normalizeMinerTelemetryEvent("nope")).toThrow("invalid_event");
52+
expect(() => normalizeMinerTelemetryEvent({ ...base, eventType: "mystery" })).toThrow("invalid_event_type");
53+
expect(() => normalizeMinerTelemetryEvent({ eventType: 7, outcome: "ok" })).toThrow("invalid_event_type");
54+
expect(() => normalizeMinerTelemetryEvent({ ...base, outcome: "great" })).toThrow("invalid_outcome");
55+
expect(() => normalizeMinerTelemetryEvent({ eventType: "query_issued", outcome: 5 })).toThrow("invalid_outcome");
56+
});
57+
58+
it("anti-leak guard: rejects a non-hash identifier (raw owner/repo, whitespace, non-string, or empty)", () => {
59+
expect(() => normalizeMinerTelemetryEvent({ ...base, repoHash: "acme/widgets" })).toThrow("invalid_repo_hash");
60+
expect(() => normalizeMinerTelemetryEvent({ ...base, repoHash: "has space" })).toThrow("invalid_repo_hash");
61+
expect(() => normalizeMinerTelemetryEvent({ ...base, repoHash: " " })).toThrow("invalid_repo_hash");
62+
expect(() => normalizeMinerTelemetryEvent({ ...base, repoHash: 123 })).toThrow("invalid_repo_hash");
63+
expect(() => normalizeMinerTelemetryEvent({ ...base, issueHash: "owner/12" })).toThrow("invalid_issue_hash");
64+
});
65+
66+
it("rejects non-numeric or malformed metrics (no free text or NaN can leak through)", () => {
67+
expect(() => normalizeMinerTelemetryEvent({ ...base, metrics: { n: "twelve" } })).toThrow("invalid_metrics");
68+
expect(() => normalizeMinerTelemetryEvent({ ...base, metrics: { n: Number.NaN } })).toThrow("invalid_metrics");
69+
expect(() => normalizeMinerTelemetryEvent({ ...base, metrics: [1, 2] })).toThrow("invalid_metrics");
70+
expect(() => normalizeMinerTelemetryEvent({ ...base, metrics: null })).toThrow("invalid_metrics");
71+
});
72+
73+
it("is re-exported from the package barrel", async () => {
74+
const barrel = await import("../../packages/gittensory-engine/src/index");
75+
expect(typeof barrel.normalizeMinerTelemetryEvent).toBe("function");
76+
expect(barrel.MINER_TELEMETRY_EVENT_TYPES).toBe(MINER_TELEMETRY_EVENT_TYPES);
77+
});
78+
});

0 commit comments

Comments
 (0)