Skip to content

Flush telemetry events to remote ingest endpoint #282

@johnrwatson

Description

@johnrwatson

Context

Telemetry events are currently written to .swamp/telemetry/ as individual JSON files but never sent to a remote endpoint. We need to flush up to 25 events per CLI invocation to POST /ingest on the telemetry server (swamp-club), using a repo UUID as the distinct_id. Events are deleted after successful flush by default. Failures must be silent (visible only at debug log level).

Design Decisions

  • Flushed tracking: After successful POST, files are deleted by default. If telemetryKeepFlushed: true is set in .swamp.yaml, files are renamed to .flushed.json instead. Both flushed and unflushed files are cleaned up by the existing 2-day retention.
  • DDD architecture: New TelemetrySender port (domain interface) implemented by HttpTelemetrySender adapter (infrastructure). Mirrors the existing UpdateChecker pattern.
  • Event format: Each TelemetryEntry maps to { event: "cli_invocation", distinct_id: repoId, properties: <TelemetryEntryData> }. Sent as a batch of up to 25.
  • Flush timing: Fire-and-forget, after recording success, before cleanup. Only on success paths (matching cleanup behavior).
  • Lazy migration: Existing repos without repoId get one auto-generated on next CLI run.
  • Default endpoint: https://telemetry.swamp.club hardcoded, overridable via telemetryEndpoint in .swamp.yaml.

Changes

1. Extend RepoMarkerData with repoId, telemetryEndpoint, and telemetryKeepFlushed

File: src/infrastructure/persistence/repo_marker_repository.ts

  • Add repoId?: string, telemetryEndpoint?: string, and telemetryKeepFlushed?: boolean to RepoMarkerData
  • Update createInitMarker() to include repoId: crypto.randomUUID()
  • createUpgradeMarker already spreads existing fields, so all new fields are preserved

2. Add TelemetrySender port and extend TelemetryRepository

File: src/domain/telemetry/telemetry_sender.ts (new)

export interface TelemetrySender {
  sendBatch(entries: TelemetryEntry[], distinctId: string): Promise<boolean>;
}

File: src/domain/telemetry/repositories.ts

Add to TelemetryRepository interface:

  • findUnflushed(limit: number): Promise<TelemetryEntry[]> — returns oldest-first unflushed entries
  • markFlushed(entry: TelemetryEntry, keepFlushed?: boolean): Promise<void> — deletes the file by default; renames to .flushed.json if keepFlushed is true

3. Implement findUnflushed and markFlushed in JsonTelemetryRepository

File: src/infrastructure/persistence/json_telemetry_repository.ts

  • findUnflushed: Lists dir, filters files ending .json but NOT .flushed.json, parses, sorts by startedAt ascending, returns up to limit
  • markFlushed: Deletes telemetry-{date}-{id}.json by default. If keepFlushed=true, renames to .flushed.json instead. Silent on failure.

4. Create HttpTelemetrySender

File: src/infrastructure/telemetry/http_telemetry_sender.ts (new)

  • Accepts endpoint URL in constructor
  • sendBatch: Maps entries to { event: "cli_invocation", distinct_id, properties: entry.toData() }, POSTs to {endpoint}/ingest as batch format, 5-second timeout
  • Returns true on 202, false on any failure

5. Add flushTelemetry to TelemetryService

File: src/domain/telemetry/telemetry_service.ts

export interface TelemetryFlushConfig {
  sender: TelemetrySender;
  distinctId: string;
  batchSize?: number; // default 25
  keepFlushed?: boolean; // default false (delete after flush)
}
  • flushTelemetry(config): Fire-and-forget. Calls repository.findUnflushed(batchSize), then sender.sendBatch(), then repository.markFlushed(entry, keepFlushed) for each entry on success. Errors logged at debug level.

6. Wire up in CLI

File: src/cli/mod.ts

  • Refactor initTelemetryService to return { service, repoId, telemetryEndpoint, keepFlushed } (or null)
  • Lazy-migrate repoId: if marker has no repoId, generate one and write it back
  • Read telemetryKeepFlushed from marker, pass as keepFlushed in flush config
  • After recordSuccess, before cleanupOldTelemetry: create HttpTelemetrySender and call service.flushTelemetry()

7. Update exports

File: src/domain/telemetry/mod.ts — export new types

.swamp.yaml Configuration

swampVersion: "20260211.120000.0"
initializedAt: "2026-02-11T12:00:00.000Z"
repoId: "550e8400-e29b-41d4-a716-446655440000"         # auto-generated
telemetryEndpoint: "http://localhost:8090"               # optional override
telemetryKeepFlushed: true                               # optional, default false

Tests

  • json_telemetry_repository_test.ts: findUnflushed filters correctly, sorts oldest-first, respects limit; markFlushed deletes by default; markFlushed renames when keepFlushed=true; deleteOlderThan cleans both .json and .flushed.json
  • http_telemetry_sender_test.ts: correct body format, returns true/false based on status
  • telemetry_service_test.ts: flushTelemetry calls sendBatch then markFlushed; skips markFlushed on send failure; no-op when no unflushed entries
  • repo_marker_repository_test.ts: createInitMarker includes repoId

Telemetry Server Changes

None required. The existing /ingest endpoint accepts the batch format and stores properties as-is. The insert_id generated server-side provides idempotency if events are sent twice.

Verification

  1. deno check — type checking
  2. deno lint — linting
  3. deno fmt — formatting
  4. deno run test — all tests pass
  5. swamp init on a fresh dir — verify .swamp.yaml contains repoId
  6. Run any command, verify .swamp/telemetry/ file is created then deleted after flush
  7. Set telemetryKeepFlushed: true in .swamp.yaml — verify file renamed to .flushed.json instead
  8. Run with --log-level debug and telemetry server down — verify debug output about flush failure
  9. deno run compile — recompile binary

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions