diff --git a/.changeset/quiet-actions-handle.md b/.changeset/quiet-actions-handle.md new file mode 100644 index 000000000..7df635ed1 --- /dev/null +++ b/.changeset/quiet-actions-handle.md @@ -0,0 +1,5 @@ +--- +"@evlog/telemetry": patch +--- + +Fix `createGitHubActionsTelemetry()` to expose `set()`, `flush()`, and a live `enabled` getter alongside `run()`. diff --git a/apps/telemetry/server/utils/allowed-tools.ts b/apps/telemetry/server/utils/allowed-tools.ts index ff1f894f5..f7516c0c4 100644 --- a/apps/telemetry/server/utils/allowed-tools.ts +++ b/apps/telemetry/server/utils/allowed-tools.ts @@ -1,5 +1,5 @@ /** Tool names accepted by `/api/telemetry/ingest` when no override is configured. */ -export const DEFAULT_ALLOWED_TOOLS = ['evlog-cli'] +export const DEFAULT_ALLOWED_TOOLS = ['evlog-cli', 'evlog-action'] /** * Custom field keys accepted per tool. Mirrors `telemetry.set()` calls in each @@ -17,6 +17,23 @@ export const DEFAULT_ALLOWED_TOOLS = ['evlog-cli'] * enrichers or map rules change. */ export const DEFAULT_ALLOWED_CUSTOM_KEYS: Record = { + 'evlog-action': [ + 'ghaAction', + 'ghaEvent', + 'baselineMode', + 'checkOutcome', + 'commentOutcome', + 'packages', + 'entryPoints', + 'score', + 'instrumented', + 'partial', + 'dark', + 'regressions', + 'fixed', + 'gatePassed', + 'baselineDelta', + ], 'evlog-cli': [ // evlog doctor — which part of a setup people get stuck on 'checksFailed', diff --git a/apps/telemetry/test/action-ingest.test.ts b/apps/telemetry/test/action-ingest.test.ts new file mode 100644 index 000000000..d2cf7fb0f --- /dev/null +++ b/apps/telemetry/test/action-ingest.test.ts @@ -0,0 +1,58 @@ +import { parseIngestBody } from '@evlog/telemetry/ingest' +import { describe, expect, it } from 'vitest' +import { parseAllowedCustomKeys, parseAllowedTools } from '../server/utils/allowed-tools' + +const custom = { + ghaAction: 'evloghq/action', + ghaEvent: 'pull_request', + baselineMode: 'base', + checkOutcome: 'created', + commentOutcome: 'updated', + packages: 2, + entryPoints: 10, + score: 80, + instrumented: 6, + partial: 2, + dark: 2, + regressions: 1, + fixed: 3, + gatePassed: false, + baselineDelta: -5, +} + +function body(fields: Record) { + return JSON.stringify({ events: [ + { + event: 'run', + command: 'map', + durationMs: 500, + outcome: 'success', + flags: { gate: false }, + tool: { name: 'evlog-action', version: 'v1' }, + env: { node: 'v24.0.0', ci: true, provider: 'github_actions', tty: false, agent: null, os: 'linux', arch: 'x64', environment: 'production' }, + custom: fields, + idempotencyKey: 'action-run-test', + timestamp: '2026-10-05T12:00:00.000Z', + } + ] }) +} + +const options = () => ({ allowedTools: parseAllowedTools(undefined), allowedCustomKeys: parseAllowedCustomKeys(undefined) }) + +describe('action telemetry ingestion', () => { + it('accepts action metadata, aggregate scan totals and reporting outcomes', () => { + const events = parseIngestBody(body(custom), options()) + expect(events).toHaveLength(1) + expect(events[0]?.custom).toEqual(custom) + expect(events[0]?.tool).toEqual({ name: 'evlog-action', version: 'v1' }) + }) + + it('drops repository data and undeclared fields', () => { + const events = parseIngestBody(body({ ...custom, repository: 'private/repo', sha: 'secret', token: 'secret', path: '/private/source.ts' }), options()) + expect(events[0]?.custom).toEqual(custom) + }) + + it('keeps explicit deployment tool overrides authoritative', () => { + expect(() => parseIngestBody(body(custom), { ...options(), allowedTools: parseAllowedTools('evlog-cli') })).toThrow('unknown tool') + }) +}) diff --git a/apps/telemetry/test/allowed-tools.test.ts b/apps/telemetry/test/allowed-tools.test.ts index dcf1d49f2..fcf056914 100644 --- a/apps/telemetry/test/allowed-tools.test.ts +++ b/apps/telemetry/test/allowed-tools.test.ts @@ -39,6 +39,7 @@ describe('parseAllowedCustomKeys', () => { it('lets an override replace the default keys for evlog-cli', () => { expect(parseAllowedCustomKeys('{"evlog-cli":["onlyThis"]}')).toEqual({ + ...DEFAULT_ALLOWED_CUSTOM_KEYS, 'evlog-cli': ['onlyThis'], }) }) diff --git a/packages/telemetry/src/github-actions.ts b/packages/telemetry/src/github-actions.ts index b5ef4edcc..d47e60d02 100644 --- a/packages/telemetry/src/github-actions.ts +++ b/packages/telemetry/src/github-actions.ts @@ -26,7 +26,15 @@ export function createGitHubActionsTelemetry< const eventType = options.eventType ?? process.env.GITHUB_EVENT_NAME ?? 'unknown' return { - ...base, + get enabled() { + return base.enabled + }, + set(fields) { + base.set(fields) + }, + flush() { + return base.flush() + }, run(command, fn, opts) { return base.run(command, fn, { ...opts, diff --git a/packages/telemetry/test/github-actions.test.ts b/packages/telemetry/test/github-actions.test.ts new file mode 100644 index 000000000..5adf0693b --- /dev/null +++ b/packages/telemetry/test/github-actions.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createGitHubActionsTelemetry } from '../src/github-actions' +import { disableTelemetry, enableTelemetry, _resetActiveTelemetryForTests } from '../src/create' +import type { RunEvent } from '../src/types' + +const TOOL = 'github-actions-test' + +describe('createGitHubActionsTelemetry', () => { + let directory: string + let events: RunEvent[] + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'evlog-gha-test-')) + events = [] + vi.stubEnv('XDG_CONFIG_HOME', directory) + vi.stubEnv('DO_NOT_TRACK', '0') + vi.stubEnv('EVLOG_TELEMETRY', '1') + vi.stubEnv('EVLOG_TELEMETRY_ENDPOINT', 'https://telemetry.test/ingest') + vi.stubEnv('GITHUB_ACTION', 'test-action') + vi.stubEnv('GITHUB_EVENT_NAME', 'push') + vi.stubGlobal('fetch', vi.fn((_url: string, init: RequestInit) => { + events.push(...JSON.parse(String(init.body)).events) + return Promise.resolve(new Response(null, { status: 204 })) + })) + }) + + afterEach(async () => { + _resetActiveTelemetryForTests() + vi.unstubAllGlobals() + vi.unstubAllEnvs() + await rm(directory, { recursive: true, force: true }) + }) + + it('exposes the full handle and records custom fields with action metadata', async () => { + const handle = createGitHubActionsTelemetry({ name: TOOL, version: '1.0.0' }) + expect(handle.enabled).toBe(true) + expect(typeof handle.set).toBe('function') + expect(typeof handle.flush).toBe('function') + await handle.flush() + const value = await handle.run('map', () => { + handle.set({ routes: 3 }) + return 42 + }) + await handle.flush() + expect(value).toBe(42) + expect(events).toHaveLength(1) + expect(events[0]?.custom).toEqual({ routes: 3, ghaAction: 'test-action', ghaEvent: 'push' }) + }) + + it('reflects consent changes through the enabled getter', async () => { + const handle = createGitHubActionsTelemetry({ name: TOOL, version: '1.0.0' }) + await disableTelemetry(TOOL) + expect(handle.enabled).toBe(false) + await enableTelemetry(TOOL) + expect(handle.enabled).toBe(true) + }) + + it.each([{ DO_NOT_TRACK: '1' }, { EVLOG_TELEMETRY: '0' }])('runs work without delivery when opted out with %j', async (env) => { + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value) + const handle = createGitHubActionsTelemetry({ name: TOOL, version: '1.0.0' }) + expect(handle.enabled).toBe(false) + expect(await handle.run('map', () => 42)).toBe(42) + await handle.flush() + expect(fetch).not.toHaveBeenCalled() + }) +})