Files
PaperClipAI/scripts/extract-proposed-events.mjs
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

359 lines
12 KiB
JavaScript

#!/usr/bin/env node
import { readFileSync } from "node:fs";
import { isAbsolute, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
export const PROPOSED_TELEMETRY_SCHEMA_VERSION = "proposed-telemetry-extractor.v2";
const DEFAULT_EVENTS_FILE = "packages/shared/src/telemetry/events.ts";
const EVENT_NAME_PATTERN = /^[a-z0-9][a-z0-9._:-]{1,63}$/;
const ISSUE_PATTERN = /^(PAP-\d+|https:\/\/github\.com\/paperclipai\/paperclip\/issues\/\d+)$/;
export function assertRepoRelativePath(value) {
if (typeof value !== "string" || value.length === 0) {
throw new Error("provenance.file must be a non-empty repo-relative path");
}
if (value.startsWith("/") || isAbsolute(value)) {
throw new Error(`provenance.file must be repo-relative: ${value}`);
}
if (/^[A-Za-z]:/.test(value)) {
throw new Error(`provenance.file must not use a drive-letter path: ${value}`);
}
if (value.includes("\\")) {
throw new Error(`provenance.file must use forward slashes: ${value}`);
}
const parts = value.split("/");
if (parts.some((part) => part === "" || part === "." || part === "..")) {
throw new Error(`provenance.file contains an unsafe path segment: ${value}`);
}
if (!/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(value)) {
throw new Error(`provenance.file contains unsupported characters: ${value}`);
}
return value;
}
export function toRepoRelativePath(repoRoot, filePath) {
const absoluteRoot = resolve(repoRoot);
const absoluteFile = resolve(filePath);
const repoRelative = relative(absoluteRoot, absoluteFile).split(sep).join("/");
if (repoRelative === "" || repoRelative === ".." || repoRelative.startsWith("../")) {
throw new Error(`events file must be inside repo root: ${filePath}`);
}
return assertRepoRelativePath(repoRelative);
}
export function parseProposedTelemetryDirective(commentText) {
const normalized = normalizeComment(commentText);
if (!normalized.includes("@ts-expect-error")) return null;
const marker = normalized.match(/@ts-expect-error\b(?:\s*--\s*)?(?:proposed-telemetry\(([^)]+)\):\s*(.*))?/s);
if (!marker) return null;
const issue = marker[1]?.trim() || null;
const text = marker[2]?.trim() || null;
if (issue && !ISSUE_PATTERN.test(issue)) {
throw new Error(
`proposed telemetry rationale issue must be PAP-<digits> or a paperclipai/paperclip GitHub issue URL: ${issue}`,
);
}
return {
issue,
text,
missingIssue: issue === null,
missingRationale: text === null,
};
}
export function extractProposedEvents(options = {}) {
const repoRoot = resolve(options.repoRoot ?? process.cwd());
const eventsFile = resolve(repoRoot, options.eventsFile ?? DEFAULT_EVENTS_FILE);
const provenanceFile = toRepoRelativePath(repoRoot, eventsFile);
const sourceText = readFileSync(eventsFile, "utf8");
const sourceFile = ts.createSourceFile(eventsFile, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const proposals = new Map();
function recordProposal(wrapper, wrapperName, eventNameNode, directive) {
const name = eventNameNode.text;
assertEventName(name, "event name");
const position = sourceFile.getLineAndCharacterOfPosition(eventNameNode.getStart(sourceFile));
const dimensions = extractWrapperDimensions(wrapper, sourceFile, wrapperName);
let proposal = proposals.get(name);
if (!proposal) {
proposal = {
name,
dimensions: new Map(),
rationale: { issue: null, text: null },
provenance: [],
};
proposals.set(name, proposal);
}
mergeRationale(proposal.rationale, directive, name);
for (const dimension of dimensions) {
const existing = proposal.dimensions.get(dimension.name);
if (existing && existing.type !== dimension.type) {
throw new Error(
`conflicting inferred types for dimension ${dimension.name} on proposed event ${name}: ${existing.type} vs ${dimension.type}`,
);
}
proposal.dimensions.set(dimension.name, dimension);
}
proposal.provenance.push({
file: provenanceFile,
line: position.line + 1,
column: position.character,
wrapper: wrapperName,
});
}
function visitWrapper(wrapper, wrapperName = wrapper.name?.text ?? "<anonymous>") {
if (!wrapper.body) return;
function visit(node) {
if (ts.isCallExpression(node) && isIdentifierTrackCall(node)) {
const eventNameNode = node.arguments[0];
if (eventNameNode && ts.isStringLiteral(eventNameNode)) {
const directive = findDirectiveForNode(sourceText, eventNameNode);
if (directive) recordProposal(wrapper, wrapperName, eventNameNode, directive);
}
}
ts.forEachChild(node, visit);
}
visit(wrapper.body);
}
function visit(node) {
if (ts.isFunctionDeclaration(node)) {
visitWrapper(node);
} else if (ts.isVariableDeclaration(node)) {
visitVariableWrapper(node);
}
ts.forEachChild(node, visit);
}
function visitVariableWrapper(node) {
if (!ts.isIdentifier(node.name)) return;
const initializer = node.initializer;
if (!initializer || (!ts.isArrowFunction(initializer) && !ts.isFunctionExpression(initializer))) return;
visitWrapper(initializer, node.name.text);
}
visit(sourceFile);
return {
schemaVersion: PROPOSED_TELEMETRY_SCHEMA_VERSION,
source: buildSource(options),
proposals: [...proposals.values()]
.map(formatProposal)
.sort((a, b) => a.name.localeCompare(b.name)),
};
}
function buildSource(options) {
const source = {
repo: options.repo ?? "paperclipai/paperclip",
ref: options.ref ?? process.env.GITHUB_SHA ?? process.env.PAPERCLIP_WORKSPACE_REPO_REF ?? "unknown",
};
const baseRef = options.baseRef ?? process.env.GITHUB_BASE_REF;
if (baseRef) source.baseRef = baseRef;
return source;
}
function isIdentifierTrackCall(node) {
const callee = node.expression;
return (
ts.isPropertyAccessExpression(callee) &&
callee.name.text === "track" &&
ts.isIdentifier(callee.expression)
);
}
function findDirectiveForNode(sourceText, node) {
const ranges = ts.getLeadingCommentRanges(sourceText, node.getFullStart()) ?? [];
for (const range of ranges) {
const directive = parseProposedTelemetryDirective(sourceText.slice(range.pos, range.end));
if (directive) return directive;
}
return null;
}
function normalizeComment(commentText) {
return commentText
.replace(/^\s*\/\//, "")
.replace(/^\s*\/\*/, "")
.replace(/\*\/\s*$/, "")
.split("\n")
.map((line) => line.replace(/^\s*\*\s?/, "").trim())
.join(" ")
.trim();
}
function extractWrapperDimensions(wrapper, sourceFile, wrapperName = wrapper.name?.text ?? "<anonymous>") {
const dimsParam = wrapper.parameters.find(
(parameter) => ts.isIdentifier(parameter.name) && parameter.name.text === "dims",
);
if (!dimsParam) return [];
if (!dimsParam.type) {
throw new Error(`wrapper ${wrapperName} has an untyped dims parameter`);
}
const typeNode = unwrapTypeNode(dimsParam.type);
if (!ts.isTypeLiteralNode(typeNode)) {
throw new Error(`wrapper ${wrapperName} dims parameter must be a type literal`);
}
return typeNode.members.map((member) => extractDimension(member, sourceFile));
}
function extractDimension(member, sourceFile) {
if (!ts.isPropertySignature(member) || !member.type) {
throw new Error("dims parameter type may contain only typed property signatures");
}
const name = propertyNameText(member.name);
assertEventName(name, "dimension name");
return {
name,
type: classifyTypeNode(member.type, sourceFile),
};
}
function propertyNameText(name) {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
}
throw new Error("dimension names must be literal identifiers or string literals");
}
function classifyTypeNode(typeNode, sourceFile) {
const node = unwrapTypeNode(typeNode);
if (node.kind === ts.SyntaxKind.StringKeyword) return "string";
if (node.kind === ts.SyntaxKind.NumberKeyword) return "number";
if (node.kind === ts.SyntaxKind.BooleanKeyword) return "boolean";
if (ts.isLiteralTypeNode(node)) {
const literal = node.literal;
if (ts.isStringLiteral(literal) || literal.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) return "string";
if (ts.isNumericLiteral(literal)) return "number";
if (literal.kind === ts.SyntaxKind.TrueKeyword || literal.kind === ts.SyntaxKind.FalseKeyword) return "boolean";
}
if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === "RawDimension") {
const inner = node.typeArguments?.[0];
if (!inner) throw new Error(`RawDimension is missing a type argument at ${node.getText(sourceFile)}`);
return classifyTypeNode(inner, sourceFile);
}
if (ts.isUnionTypeNode(node)) {
const primitiveTypes = new Set();
for (const member of node.types) {
const unwrapped = unwrapTypeNode(member);
if (unwrapped.kind === ts.SyntaxKind.UndefinedKeyword) continue;
if (unwrapped.kind === ts.SyntaxKind.NullKeyword) continue;
if (ts.isLiteralTypeNode(unwrapped) && unwrapped.literal.kind === ts.SyntaxKind.NullKeyword) continue;
primitiveTypes.add(classifyTypeNode(unwrapped, sourceFile));
}
if (primitiveTypes.size !== 1) {
throw new Error(`dimension union must resolve to one primitive type: ${node.getText(sourceFile)}`);
}
return [...primitiveTypes][0];
}
throw new Error(`unsupported dimension type: ${node.getText(sourceFile)}`);
}
function unwrapTypeNode(typeNode) {
let node = typeNode;
while (ts.isParenthesizedTypeNode(node)) {
node = node.type;
}
return node;
}
function assertEventName(value, label) {
if (!EVENT_NAME_PATTERN.test(value)) {
throw new Error(`${label} must match ${EVENT_NAME_PATTERN}: ${value}`);
}
}
function mergeRationale(target, incoming, eventName) {
mergeRationaleField(target, incoming, "issue", eventName);
mergeRationaleField(target, incoming, "text", eventName);
}
function mergeRationaleField(target, incoming, key, eventName) {
const value = incoming[key];
if (!value) return;
if (target[key] && target[key] !== value) {
throw new Error(`conflicting rationale ${key} for proposed event ${eventName}`);
}
target[key] = value;
}
function formatProposal(proposal) {
const provenance = proposal.provenance
.map((item) => ({
file: assertRepoRelativePath(item.file),
line: item.line,
column: item.column,
}))
.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
return {
name: proposal.name,
dimensions: [...proposal.dimensions.values()].sort((a, b) => a.name.localeCompare(b.name)),
rationale: {
issue: proposal.rationale.issue,
text: proposal.rationale.text,
missingIssue: proposal.rationale.issue === null,
missingRationale: proposal.rationale.text === null,
},
provenance,
};
}
function parseArgs(argv) {
const options = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
continue;
}
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
throw new Error(`${arg} requires a value`);
}
index += 1;
if (arg === "--repo-root") options.repoRoot = next;
else if (arg === "--events-file") options.eventsFile = next;
else if (arg === "--repo") options.repo = next;
else if (arg === "--ref") options.ref = next;
else if (arg === "--base-ref") options.baseRef = next;
else throw new Error(`unknown option: ${arg}`);
}
return options;
}
function printHelp() {
process.stdout.write(`Usage: node scripts/extract-proposed-events.mjs [options]\n\nOptions:\n --repo-root <path> Repository root. Defaults to cwd.\n --events-file <path> events.ts path, absolute or repo-relative.\n --repo <slug> Source repository slug. Defaults to paperclipai/paperclip.\n --ref <ref> Source ref/SHA for the extractor envelope.\n --base-ref <ref> Optional base ref for diff-oriented inventory jobs.\n`);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
try {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
process.exit(0);
}
process.stdout.write(`${JSON.stringify(extractProposedEvents(options), null, 2)}\n`);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
}