diff --git a/src/sync/cli.ts b/src/sync/cli.ts index 1c40ec1d..509a112f 100644 --- a/src/sync/cli.ts +++ b/src/sync/cli.ts @@ -23,7 +23,7 @@ import { import { createCredentialStore } from './credentials.js' import { readSyncConfig, writeSyncConfig, deleteSyncConfig, updateLastSync } from './config.js' import { collectUnsentCalls, collectUnsentAttribution, sendBatches, sendAttributionBatches, batchCalls, MAX_PER_PUSH, MAX_ATTRIBUTION_PER_PUSH, type PushResult } from './push.js' -import { batchAttributionItems } from './otlp.js' +import { batchAttributionItems, wireProjectName } from './otlp.js' export function registerSyncCommands(program: Command): void { const sync = program @@ -274,7 +274,8 @@ export function registerSyncCommands(program: Command): void { process.exit(1) } const { range } = getDateRange(period) - const projects = await parseAllSessions(range) + const projects = (await parseAllSessions(range)) + .map(p => ({ ...p, project: wireProjectName(p.projectPath, p.project) })) // Flatten + filter against sent-ledger const { allCalls, unsent, held, frozen } = collectUnsentCalls(projects) diff --git a/src/sync/otlp.ts b/src/sync/otlp.ts index 03c7f11c..2395a2ea 100644 --- a/src/sync/otlp.ts +++ b/src/sync/otlp.ts @@ -65,6 +65,16 @@ export function deriveTraceId(sessionId: string): string { return createHash('sha256').update(sessionId).digest('hex').slice(0, 32) } +/** + * Wire-safe project identity: the leaf directory name. Several providers key a + * project by its sanitized absolute path ("-Users-me-Projects-app"), and + * docs/sync/README.md promises file paths stay local. + */ +export function wireProjectName(projectPath: string, fallback: string): string { + const normalized = projectPath.trim().replace(/\\/g, '/').replace(/\/+$/, '') + return normalized.split('/').filter(Boolean).pop() ?? fallback +} + // --- Timestamp conversion --- function toUnixNano(isoTimestamp: string): string { diff --git a/tests/sync-attribution-cli.test.ts b/tests/sync-attribution-cli.test.ts index 295350fc..65048453 100644 --- a/tests/sync-attribution-cli.test.ts +++ b/tests/sync-attribution-cli.test.ts @@ -10,7 +10,7 @@ import { execFileSync } from 'node:child_process' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest' import { Command } from 'commander' @@ -154,4 +154,24 @@ describe('sync push --attribution (CLI level)', () => { const wire = JSON.stringify(idp.tracesRequests) expect(wire).toContain('github.com/acme/cli-widget') }) + + it('never puts a filesystem path on the wire as ai.project', async () => { + const now = Date.now() + const first = new Date(now - 90 * 60 * 1000).toISOString() + const last = new Date(now - 30 * 60 * 1000).toISOString() + const session = makeSession('cli-sess-1', first, last, [makeCall(`call-${now}`, first)]) + parseAllSessionsMock.mockResolvedValue([ + { project: `-${repoDir.replace(/^\//, '').replace(/\//g, '-')}`, projectPath: repoDir, sessions: [session] } as ProjectSummary, + ]) + await runPush(['--attribution']) + const wire = JSON.stringify(idp.tracesRequests) + expect(wire).not.toContain(repoDir) // no absolute path + expect(wire).not.toContain(repoDir.replace(/\//g, '-')) // no slugified path + const projects = idp.tracesRequests.flatMap(r => (r.body as any).resourceSpans + .flatMap((rs: any) => rs.scopeSpans.flatMap((ss: any) => ss.spans)) + .flatMap((s: any) => s.attributes.filter((a: any) => a.key === 'ai.project') + .map((a: any) => a.value.stringValue))) + expect(projects.length).toBeGreaterThanOrEqual(2) // usage + attribution spans + expect(new Set(projects)).toEqual(new Set([basename(repoDir)])) + }) })