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
deno check — type checking
deno lint — linting
deno fmt — formatting
deno run test — all tests pass
swamp init on a fresh dir — verify .swamp.yaml contains repoId
- Run any command, verify
.swamp/telemetry/ file is created then deleted after flush
- Set
telemetryKeepFlushed: true in .swamp.yaml — verify file renamed to .flushed.json instead
- Run with
--log-level debug and telemetry server down — verify debug output about flush failure
deno run compile — recompile binary
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 toPOST /ingeston the telemetry server (swamp-club), using a repo UUID as thedistinct_id. Events are deleted after successful flush by default. Failures must be silent (visible only at debug log level).Design Decisions
telemetryKeepFlushed: trueis set in.swamp.yaml, files are renamed to.flushed.jsoninstead. Both flushed and unflushed files are cleaned up by the existing 2-day retention.TelemetrySenderport (domain interface) implemented byHttpTelemetrySenderadapter (infrastructure). Mirrors the existingUpdateCheckerpattern.TelemetryEntrymaps to{ event: "cli_invocation", distinct_id: repoId, properties: <TelemetryEntryData> }. Sent as a batch of up to 25.repoIdget one auto-generated on next CLI run.https://telemetry.swamp.clubhardcoded, overridable viatelemetryEndpointin.swamp.yaml.Changes
1. Extend
RepoMarkerDatawithrepoId,telemetryEndpoint, andtelemetryKeepFlushedFile:
src/infrastructure/persistence/repo_marker_repository.tsrepoId?: string,telemetryEndpoint?: string, andtelemetryKeepFlushed?: booleantoRepoMarkerDatacreateInitMarker()to includerepoId: crypto.randomUUID()createUpgradeMarkeralready spreads existing fields, so all new fields are preserved2. Add
TelemetrySenderport and extendTelemetryRepositoryFile:
src/domain/telemetry/telemetry_sender.ts(new)File:
src/domain/telemetry/repositories.tsAdd to
TelemetryRepositoryinterface:findUnflushed(limit: number): Promise<TelemetryEntry[]>— returns oldest-first unflushed entriesmarkFlushed(entry: TelemetryEntry, keepFlushed?: boolean): Promise<void>— deletes the file by default; renames to.flushed.jsonifkeepFlushedis true3. Implement
findUnflushedandmarkFlushedinJsonTelemetryRepositoryFile:
src/infrastructure/persistence/json_telemetry_repository.tsfindUnflushed: Lists dir, filters files ending.jsonbut NOT.flushed.json, parses, sorts bystartedAtascending, returns up tolimitmarkFlushed: Deletestelemetry-{date}-{id}.jsonby default. IfkeepFlushed=true, renames to.flushed.jsoninstead. Silent on failure.4. Create
HttpTelemetrySenderFile:
src/infrastructure/telemetry/http_telemetry_sender.ts(new)sendBatch: Maps entries to{ event: "cli_invocation", distinct_id, properties: entry.toData() }, POSTs to{endpoint}/ingestas batch format, 5-second timeouttrueon 202,falseon any failure5. Add
flushTelemetrytoTelemetryServiceFile:
src/domain/telemetry/telemetry_service.tsflushTelemetry(config): Fire-and-forget. Callsrepository.findUnflushed(batchSize), thensender.sendBatch(), thenrepository.markFlushed(entry, keepFlushed)for each entry on success. Errors logged at debug level.6. Wire up in CLI
File:
src/cli/mod.tsinitTelemetryServiceto return{ service, repoId, telemetryEndpoint, keepFlushed }(or null)repoId: if marker has norepoId, generate one and write it backtelemetryKeepFlushedfrom marker, pass askeepFlushedin flush configrecordSuccess, beforecleanupOldTelemetry: createHttpTelemetrySenderand callservice.flushTelemetry()7. Update exports
File:
src/domain/telemetry/mod.ts— export new types.swamp.yamlConfigurationTests
json_telemetry_repository_test.ts:findUnflushedfilters correctly, sorts oldest-first, respects limit;markFlusheddeletes by default;markFlushedrenames when keepFlushed=true;deleteOlderThancleans both.jsonand.flushed.jsonhttp_telemetry_sender_test.ts: correct body format, returns true/false based on statustelemetry_service_test.ts:flushTelemetrycalls sendBatch then markFlushed; skips markFlushed on send failure; no-op when no unflushed entriesrepo_marker_repository_test.ts:createInitMarkerincludesrepoIdTelemetry Server Changes
None required. The existing
/ingestendpoint accepts the batch format and storespropertiesas-is. Theinsert_idgenerated server-side provides idempotency if events are sent twice.Verification
deno check— type checkingdeno lint— lintingdeno fmt— formattingdeno run test— all tests passswamp initon a fresh dir — verify.swamp.yamlcontainsrepoId.swamp/telemetry/file is created then deleted after flushtelemetryKeepFlushed: truein.swamp.yaml— verify file renamed to.flushed.jsoninstead--log-level debugand telemetry server down — verify debug output about flush failuredeno run compile— recompile binary