Files
PaperClipAI/scripts/extract-proposed-events.test.mjs
T
Nicky LeachandPaperclip 90f85a7d11 Add telemetry proposal extractor (#9544)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - It emits telemetry events to understand product usage — registered
event names are gated by a generated `PAPERCLIP_EVENTS` registry so the
client only enqueues known, schema-approved events
> - When product teams want to instrument a new behaviour, they must
first register the event name — but schema registration is a
commit-and-release cycle, which creates friction in fast-moving product
iterations
> - A proposal lane is needed: let developers mark a `track()` call with
a typed `@ts-expect-error` proposal marker so the event name can be
reviewed and tracked in CI before the schema is formally registered
> - The existing client had no guard against unregistered event names,
so any call with an out-of-registry name (or a prototype-inherited key)
would silently enter the queue, state, and network flush path
> - This PR adds an `Object.hasOwn(PAPERCLIP_EVENTS, eventName)` guard
at the entry point of `track()` to swallow unregistered calls before any
side effects, adds `scripts/extract-proposed-events.mjs` to scan source
for proposal markers and emit a v2 JSON manifest with provenance and
rationale, and documents the complete proposal workflow
> - The benefit is that new instrumentation can be proposed and reviewed
in code without touching the registered schema, and tooling can surface
missing rationale before events graduate to stable

## Linked Issues or Issue Description

No existing GitHub issue covers this change. This PR introduces a new
feature.

**Feature motivation:** Paperclip's telemetry schema is intentionally
stable — registered event names are code-generated and gated. Product
engineers who want to instrument a new behaviour today must land a
schema change first, creating a two-step process that slows iteration. A
proposal lane lets developers write the instrumentation call ahead of
schema registration, protected by a compile-time `@ts-expect-error`
marker that an extractor script can surface for review. This PR
implements both the client-side safety gate and the extraction tooling.

Refs: #9518 (closed predecessor — docs-only; this PR supersedes it with
the full implementation)

## What Changed

- Added `Object.hasOwn(PAPERCLIP_EVENTS, eventName)` guard at the top of
`TelemetryClient.track()`: unregistered event names (including
prototype-inherited keys) are now swallowed before any state, queue, or
network operation
- Added `scripts/extract-proposed-events.mjs`: scans TypeScript source
for `@ts-expect-error -- proposed-telemetry(<issue>): <rationale>`
markers; emits a v2 JSON manifest per proposed event including name,
rationale, provenance (repo-relative file + line), and a
`rationale_missing` flag for CI enforcement
- Added `scripts/extract-proposed-events.test.mjs`: test suite covering
marker parsing, multi-line markers, path validation, out-of-repo
rejection, and the v2 schema output contract
- Added `doc/TELEMETRY_WORKFLOW.md`: documents the proposal workflow,
the canonical multi-line marker example, rationale requirements, and how
to graduate a proposed event to stable schema
- Updated `packages/shared/src/telemetry/README.md`: added "Proposed
Events" section to the Telemetry Data Contract per the contributing
guide requirement for telemetry changes

## Verification

Run all of the following from the repo root:

```sh
# Extractor unit tests
node --test scripts/extract-proposed-events.test.mjs

# Telemetry client + types tests
pnpm exec vitest run --config vitest.config.ts \
  src/telemetry/client.test.ts src/telemetry/client-types.test.ts \
  --reporter=verbose
# (run from packages/shared)

# Type-check
pnpm --filter @paperclipai/shared typecheck

# Smoke-run the extractor in local-test mode
node scripts/extract-proposed-events.mjs --ref local-test
```

All four commands pass locally.

## Risks

- **Silent drop on unregistered events:** The `Object.hasOwn` guard
fails closed — any event name not in `PAPERCLIP_EVENTS` is silently
dropped. If the generated registry is missing an event that was
previously tracked, those calls will be silently lost. Mitigation: the
extractor script surfaces proposed events that need registration; the
TypeScript type system already enforces `TelemetryEventName ⊆
PAPERCLIP_EVENTS` at compile time.
- **Extractor is read-only:** `extract-proposed-events.mjs` reads source
and emits JSON; it does not modify any files. No runtime or schema risk.
- Overall risk: **low**. The guard is additive and defensive; the
extractor and docs are additive only.

## Model Used

- Provider: Anthropic
- Model ID: `claude-sonnet-4-6`
- Context window: 200 K tokens
- Capabilities: tool use, extended context, code generation

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-13 23:47:27 -07:00

262 lines
8.8 KiB
JavaScript

import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import ts from "typescript";
import {
PROPOSED_TELEMETRY_SCHEMA_VERSION,
assertRepoRelativePath,
extractProposedEvents,
toRepoRelativePath,
} from "./extract-proposed-events.mjs";
function withFixtureRepo(source, callback) {
const repoRoot = mkdtempSync(join(tmpdir(), "paperclip-proposed-events-"));
const eventsFile = join(repoRoot, "packages", "shared", "src", "telemetry", "events.ts");
mkdirSync(join(repoRoot, "packages", "shared", "src", "telemetry"), { recursive: true });
writeFileSync(eventsFile, source);
try {
return callback({ repoRoot, eventsFile });
} finally {
rmSync(repoRoot, { recursive: true, force: true });
}
}
const fixtureSource = `
import type { TelemetryClient } from "./client.js";
type RawDimension<T extends string | undefined> = T | (string & {});
export function trackSkillStudioCreated(
client: TelemetryClient,
dims: {
sharing_scope: RawDimension<"team" | "private">;
category_count: number;
launched_from_template: boolean;
},
): void {
client.track(
// @ts-expect-error -- proposed-telemetry(PAP-2411): measure Skill Studio create completion
"skill_studio.skill_created",
dims,
);
}
export function trackSkillStudioOpened(
client: TelemetryClient,
dims: {
surface: "modal" | "page";
},
): void {
client.track(
// @ts-expect-error
"skill_studio.opened",
dims,
);
}
export function trackInstallStarted(client: TelemetryClient): void {
client.track("install.started", {});
}
`;
test("extractor emits deterministic proposed-telemetry-extractor.v2 records", () => {
const output = withFixtureRepo(fixtureSource, ({ repoRoot, eventsFile }) =>
extractProposedEvents({ repoRoot, eventsFile, ref: "fixture-sha", baseRef: "master" }),
);
assert.equal(output.schemaVersion, PROPOSED_TELEMETRY_SCHEMA_VERSION);
assert.deepEqual(output.source, {
repo: "paperclipai/paperclip",
ref: "fixture-sha",
baseRef: "master",
});
assert.deepEqual(
output.proposals.map((proposal) => proposal.name),
["skill_studio.opened", "skill_studio.skill_created"],
);
const created = output.proposals.find((proposal) => proposal.name === "skill_studio.skill_created");
assert.deepEqual(created.dimensions, [
{ name: "category_count", type: "number" },
{ name: "launched_from_template", type: "boolean" },
{ name: "sharing_scope", type: "string" },
]);
assert.deepEqual(created.rationale, {
issue: "PAP-2411",
text: "measure Skill Studio create completion",
missingIssue: false,
missingRationale: false,
});
assert.equal(created.provenance.length, 1);
assert.equal(created.provenance[0].file, "packages/shared/src/telemetry/events.ts");
assert.equal(typeof created.provenance[0].line, "number");
assert.equal(typeof created.provenance[0].column, "number");
});
test("extractor flags a missing proposed-telemetry suffix without hard-failing", () => {
const output = withFixtureRepo(fixtureSource, ({ repoRoot, eventsFile }) =>
extractProposedEvents({ repoRoot, eventsFile, ref: "fixture-sha" }),
);
const opened = output.proposals.find((proposal) => proposal.name === "skill_studio.opened");
assert.deepEqual(opened.rationale, {
issue: null,
text: null,
missingIssue: true,
missingRationale: true,
});
assert.deepEqual(opened.dimensions, [{ name: "surface", type: "string" }]);
});
test("extractor scans TelemetryClient wrappers whose receiver is not named client", () => {
const output = withFixtureRepo(
`type TelemetryClient = { track(name: string, dims: unknown): void };
export function trackWorkspaceOpened(
telemetry: TelemetryClient,
dims: { surface: string },
): void {
telemetry.track(
// @ts-expect-error -- proposed-telemetry(PAP-2463): exercise alternate telemetry client parameter names
"workspace.opened",
dims,
);
}
`,
({ repoRoot, eventsFile }) => extractProposedEvents({ repoRoot, eventsFile, ref: "fixture-sha" }),
);
assert.deepEqual(
output.proposals.map((proposal) => proposal.name),
["workspace.opened"],
);
assert.deepEqual(output.proposals[0].dimensions, [{ name: "surface", type: "string" }]);
});
test("extractor scans variable-assigned wrappers and ignores nullable union members", () => {
const output = withFixtureRepo(
`type TelemetryClient = { track(name: string, dims: unknown): void };
export const trackWorkspaceArrow = (
telemetry: TelemetryClient,
dims: { surface: string | null | undefined },
): void => {
telemetry.track(
// @ts-expect-error -- proposed-telemetry(PAP-2463): exercise arrow wrapper extraction
"workspace.arrow_opened",
dims,
);
};
export const trackWorkspaceFunctionExpression = function (
tc: TelemetryClient,
dims: { accepted: true | false | null },
): void {
tc.track(
// @ts-expect-error -- proposed-telemetry(PAP-2463): exercise function-expression wrapper extraction
"workspace.function_expression_opened",
dims,
);
};
`,
({ repoRoot, eventsFile }) => extractProposedEvents({ repoRoot, eventsFile, ref: "fixture-sha" }),
);
assert.deepEqual(
output.proposals.map((proposal) => proposal.name),
["workspace.arrow_opened", "workspace.function_expression_opened"],
);
assert.deepEqual(output.proposals[0].dimensions, [{ name: "surface", type: "string" }]);
assert.deepEqual(output.proposals[1].dimensions, [{ name: "accepted", type: "boolean" }]);
});
test("extractor rejects invalid rationale issue references when present", () => {
assert.throws(
() =>
withFixtureRepo(
`export function trackBad(client, dims: { source: string }): void {\n client.track(\n // @ts-expect-error -- proposed-telemetry(PROJ-1): bad issue ref\n "skill_studio.bad_issue",\n dims,\n );\n}\n`,
({ repoRoot, eventsFile }) => extractProposedEvents({ repoRoot, eventsFile, ref: "fixture-sha" }),
),
/rationale issue must be PAP-<digits>/,
);
});
test("provenance paths are repo-relative and reject dev-host path shapes", () => {
assert.equal(
assertRepoRelativePath("packages/shared/src/telemetry/events.ts"),
"packages/shared/src/telemetry/events.ts",
);
assert.throws(() => assertRepoRelativePath("/tmp/events.ts"), /repo-relative/);
assert.throws(() => assertRepoRelativePath("../events.ts"), /unsafe path segment/);
assert.throws(() => assertRepoRelativePath("C:/repo/events.ts"), /drive-letter/);
assert.throws(() => assertRepoRelativePath("packages\\shared\\events.ts"), /forward slashes/);
const repoRoot = mkdtempSync(join(tmpdir(), "paperclip-provenance-root-"));
try {
assert.throws(() => toRepoRelativePath(repoRoot, join(repoRoot, "..", "events.ts")), /inside repo root/);
} finally {
rmSync(repoRoot, { recursive: true, force: true });
}
});
function diagnosticsFor(sourceText) {
const repoRoot = mkdtempSync(join(tmpdir(), "paperclip-ts2578-"));
const fileName = join(repoRoot, "fixture.ts");
writeFileSync(fileName, sourceText);
try {
const program = ts.createProgram([fileName], {
strict: true,
noEmit: true,
skipLibCheck: true,
target: ts.ScriptTarget.ES2023,
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
types: [],
});
return ts
.getPreEmitDiagnostics(program)
.filter((diagnostic) => diagnostic.file?.fileName === fileName)
.map((diagnostic) => diagnostic.code);
} finally {
rmSync(repoRoot, { recursive: true, force: true });
}
}
function tsMechanicsFixture(eventUnion, mapEntry) {
return `
type TelemetryEventName = ${eventUnion};
interface EventDimensionsMap {
"install.started": {};
${mapEntry}
}
type TelemetryEventDimensions<K extends TelemetryEventName> = EventDimensionsMap[K];
type TrackArgs<K extends TelemetryEventName> = keyof TelemetryEventDimensions<K> extends never
? [dimensions?: TelemetryEventDimensions<K>]
: [dimensions: TelemetryEventDimensions<K>];
declare const client: {
track<K extends TelemetryEventName>(eventName: K, ...args: TrackArgs<K>): void;
};
client.track(
// @ts-expect-error -- proposed-telemetry(PAP-2411): TS2578 expiry fixture
"skill_studio.skill_created",
{ sharing_scope: "team" },
);
`;
}
test("TS2578 expires the directive once a fixture event is registered", () => {
const unregistered = diagnosticsFor(tsMechanicsFixture('"install.started"', ""));
assert.deepEqual(unregistered, []);
const registered = diagnosticsFor(
tsMechanicsFixture(
'"install.started" | "skill_studio.skill_created"',
'"skill_studio.skill_created": { sharing_scope: string };',
),
);
assert.ok(registered.includes(2578), `expected TS2578, got ${registered.join(", ")}`);
});