Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
106 changes: 106 additions & 0 deletions packages/gittensory-engine/src/miner-telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> | 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<string>(MINER_TELEMETRY_EVENT_TYPES);
const telemetryOutcomeSet = new Set<string>(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<string, unknown>)) {
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<MinerTelemetryEvent>;
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),
};
}
78 changes: 78 additions & 0 deletions test/unit/miner-telemetry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});