mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Add @deepseek-ai/dsh-telemetry, a launcher-side (non-plugin) library for the ConsentResolver (parses cordis.yml consent + DO_NOT_TRACK/CI), buildTelemetryPayload (redacted cordis.yml + package.json full content, never .env), getOrCreateAnonymousId (random UUID in a per-machine global config file), and TelemetryReporter (fire-and-forget, never blocks or crashes the command). Endpoint is a fixed .invalid placeholder pending the real endpoint. Launcher dispatch wiring and the helper feature-catalog entry are intentionally out of scope. Registers the package in tsconfig references, the module graph, and the README model-experience audit map. Per-file 100% coverage.
150 lines
6.2 KiB
TypeScript
150 lines
6.2 KiB
TypeScript
/**
|
|
* Fire-and-forget telemetry reporter for the dsh-sdk launcher.
|
|
*
|
|
* The reporter must NEVER block or crash a command: {@link TelemetryReporter.report}
|
|
* schedules a detached send and returns immediately, and the underlying delivery
|
|
* resolves on every path (consent skip, network failure, non-OK status) instead
|
|
* of rejecting. {@link TelemetryReporter.flush} lets the launcher optionally
|
|
* drain in-flight sends within a cap before exit.
|
|
*
|
|
* @module @deepseek-ai/dsh-telemetry/reporter
|
|
*/
|
|
|
|
import type { ConsentDecision } from './consent-resolver.ts'
|
|
import type { TelemetryPayload } from './payload.ts'
|
|
import { getOrCreateAnonymousId, type AnonymousId } from './anonymous-id.ts'
|
|
import { SecretRedactor } from './secret-redactor.ts'
|
|
|
|
/**
|
|
* Placeholder collection endpoint. This is a fixed protocol constant, not a
|
|
* deployment tunable.
|
|
*
|
|
* FIXME(ccyu): replace with the real telemetry endpoint before release. The
|
|
* `.invalid` TLD guarantees delivery fails harmlessly until then.
|
|
*/
|
|
export const DSH_TELEMETRY_ENDPOINT = 'https://telemetry.example.invalid/v1/dsh-sdk'
|
|
|
|
/** Wire-envelope schema version; bump on any incompatible body change. */
|
|
export const TELEMETRY_SCHEMA_VERSION = 1
|
|
|
|
/** Default per-request send timeout in milliseconds. */
|
|
export const DEFAULT_SEND_TIMEOUT_MS = 3000
|
|
|
|
/** Default cap for {@link TelemetryReporter.flush} in milliseconds. */
|
|
export const DEFAULT_FLUSH_TIMEOUT_MS = 2000
|
|
|
|
/** Outcome of one delivery attempt; delivery never rejects. */
|
|
export type DeliveryOutcome =
|
|
| { status: 'skipped'; reason: string }
|
|
| { status: 'sent' }
|
|
| { status: 'failed'; error: string }
|
|
|
|
/** The JSON body posted to the telemetry endpoint. */
|
|
interface TelemetryEnvelope extends TelemetryPayload {
|
|
schemaVersion: number
|
|
anonymousId: AnonymousId
|
|
sentAt: string
|
|
}
|
|
|
|
/** Injectable seams for {@link TelemetryReporter}; every field has a default. */
|
|
export interface TelemetryReporterOptions {
|
|
/** Collection endpoint; defaults to {@link DSH_TELEMETRY_ENDPOINT}. */
|
|
endpoint?: string
|
|
/** `fetch` implementation; defaults to the global `fetch`. */
|
|
fetch?: typeof globalThis.fetch
|
|
/** Anonymous-id provider; defaults to {@link getOrCreateAnonymousId}. */
|
|
anonymousId?: () => Promise<AnonymousId>
|
|
/** Redactor applied to the assembled envelope as a final backstop; defaults to a fresh {@link SecretRedactor}. */
|
|
redactor?: SecretRedactor
|
|
/** Per-request send timeout in milliseconds. */
|
|
timeoutMs?: number
|
|
/** Clock for the envelope timestamp; defaults to `Date.now`. */
|
|
now?: () => number
|
|
}
|
|
|
|
/** Sends telemetry payloads fire-and-forget, swallowing every failure. */
|
|
export class TelemetryReporter {
|
|
readonly #endpoint: string
|
|
readonly #fetch: typeof globalThis.fetch
|
|
readonly #anonymousId: () => Promise<AnonymousId>
|
|
readonly #redactor: SecretRedactor
|
|
readonly #timeoutMs: number
|
|
readonly #now: () => number
|
|
readonly #inflight = new Set<Promise<DeliveryOutcome>>()
|
|
|
|
/** @param options - endpoint, transport, id provider, and timing seams. */
|
|
constructor(options: TelemetryReporterOptions = {}) {
|
|
this.#endpoint = options.endpoint ?? DSH_TELEMETRY_ENDPOINT
|
|
this.#fetch = options.fetch ?? globalThis.fetch
|
|
this.#anonymousId = options.anonymousId ?? getOrCreateAnonymousId
|
|
this.#redactor = options.redactor ?? new SecretRedactor()
|
|
this.#timeoutMs = options.timeoutMs ?? DEFAULT_SEND_TIMEOUT_MS
|
|
this.#now = options.now ?? Date.now
|
|
}
|
|
|
|
/**
|
|
* Schedule a detached, non-blocking send. Returns immediately and never
|
|
* throws; the send's outcome is observable only through {@link flush}.
|
|
* @param payload - the command payload to report.
|
|
* @param consent - resolved consent; a denial short-circuits to a skip.
|
|
*/
|
|
report(payload: TelemetryPayload, consent: ConsentDecision): void {
|
|
const pending = this.#deliver(payload, consent)
|
|
this.#inflight.add(pending)
|
|
void pending.finally(() => this.#inflight.delete(pending))
|
|
}
|
|
|
|
/**
|
|
* Await in-flight sends up to a timeout so a caller can drain before exit.
|
|
* Resolves on the cap regardless of send progress; never rejects.
|
|
* @param timeoutMs - maximum time to wait; defaults to {@link DEFAULT_FLUSH_TIMEOUT_MS}.
|
|
*/
|
|
async flush(timeoutMs: number = DEFAULT_FLUSH_TIMEOUT_MS): Promise<void> {
|
|
if (this.#inflight.size === 0) return
|
|
const drained = Promise.allSettled([...this.#inflight]).then(() => undefined)
|
|
let timer!: ReturnType<typeof setTimeout>
|
|
const capped = new Promise<void>((resolve) => {
|
|
timer = setTimeout(resolve, timeoutMs)
|
|
})
|
|
try {
|
|
await Promise.race([drained, capped])
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
/** Deliver one payload, resolving to an outcome on every path (never rejects). */
|
|
async #deliver(payload: TelemetryPayload, consent: ConsentDecision): Promise<DeliveryOutcome> {
|
|
if (!consent.allowed) return { status: 'skipped', reason: consent.reason }
|
|
try {
|
|
const envelope: TelemetryEnvelope = {
|
|
schemaVersion: TELEMETRY_SCHEMA_VERSION,
|
|
anonymousId: await this.#anonymousId(),
|
|
sentAt: new Date(this.#now()).toISOString(),
|
|
...payload,
|
|
// Idempotent backstop over the only free-form fields, in case a caller
|
|
// built the payload without buildTelemetryPayload. Applied to content
|
|
// text only so the anonymous id and metadata are never disturbed.
|
|
...payload.cordisYmlContent !== undefined
|
|
? { cordisYmlContent: this.#redactor.redactText(payload.cordisYmlContent) }
|
|
: {},
|
|
...payload.packageJsonContent !== undefined
|
|
? { packageJsonContent: this.#redactor.redactText(payload.packageJsonContent) }
|
|
: {},
|
|
}
|
|
const response = await this.#fetch(this.#endpoint, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(envelope),
|
|
signal: AbortSignal.timeout(this.#timeoutMs),
|
|
})
|
|
if (!response.ok) return { status: 'failed', error: `HTTP ${response.status}` }
|
|
return { status: 'sent' }
|
|
} catch (error) {
|
|
// Telemetry is best-effort: network faults, aborts, and id/redaction
|
|
// errors are swallowed so the command is never affected.
|
|
return { status: 'failed', error: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
}
|
|
}
|