Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/otel-conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# OpenTelemetry Test Conventions

The canonical span and attribute model every reporter emits to. The machine-readable source of truth is [`packages/sdk/src/conventions.ts`](../packages/sdk/src/conventions.ts); this document is the human-readable spec. Product rationale lives in [ADR-0002](adr/0002-otel-native-ingestion.md) and the [wiki](https://github.com/AKogut/flakemetry/wiki/OTel-Test-Conventions).

Conventions version: `0.1.0`.

## Span hierarchy

```
test.run root span — one CI job / suite invocation
└─ test.case one execution of one test (retries are separate cases)
└─ test.step a step / hook (added in M2)
```

M1 emits `test.run` + `test.case`. `test.step` and network/browser child spans arrive in M2.

## Resource / run attributes

| Key | Example | Meaning |
|---|---|---|
| `service.name` | `flakemetry-reporter` | standard OTel service identity |
| `flakemetry.project` | `acme/web` | project the results belong to |
| `ci.provider` | `github_actions` | CI provider |
| `ci.run_id` | `9000001` | provider run id |
| `vcs.commit_sha` | `a1b2c3d` | commit under test — enables the same-sha flake signal |
| `vcs.branch` | `main` | branch |
| `vcs.pr_number` | `42` | pull request, when applicable |

## Case span attributes

| Key | Example | Meaning |
|---|---|---|
| `test.identity.fingerprint` | `sha256:…` | stable identity (L1), computed by the reporter |
| `test.suite` | `auth` | grouping |
| `test.title` | `logs in` | display name |
| `test.file_path` | `e2e/auth/login.spec.ts` | source location |
| `test.params_hash` | `9f2c…` | parameterized bucket, omitted when absent |
| `test.status` | `pass \| fail \| skip \| flaky` | verdict |
| `test.attempt` | `2` | retry index (1-based) |
| `test.duration_ms` | `1834` | wall-clock duration |

## Status mapping

| Test status | OTel span status |
|---|---|
| `pass`, `flaky` | `OK` |
| `fail` | `ERROR` (+ exception event carrying type / message / stack) |
| `skip` | `UNSET` |

## Fingerprint (L1)

`sha256(normalized_file_path + ' ' + suite + ' ' + title + ' ' + params_hash)` where the path is workspace-relative, POSIX-separated and lowercased. This is the exact-match layer; the server-side identity engine resolves moves and renames on top of it (#18).

## OTLP → contracts mapping

The SDK records executions and produces a contract-valid `ingestRunBatch` (`@flakemetry/contracts`) directly, so the same recorded run can be exported as OTLP spans or posted as the batch payload. Field mapping:

| Batch field | Source |
|---|---|
| `resource.*` | run/resource attributes above |
| `executions[].{filePath,suite,title,status,attempt,durationMs}` | case span attributes |
| `executions[].retryOfIndex` | index of the earlier attempt in the same batch |
| `executions[].error` | the case span exception event |
| `idempotencyKey` | one per run; makes re-delivery safe |
15 changes: 13 additions & 2 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,22 @@
"build": "tsup",
"typecheck": "tsc -p tsconfig.json",
"lint": "eslint .",
"clean": "rm -rf dist .turbo"
"clean": "rm -rf dist .turbo",
"test": "vitest run"
},
"devDependencies": {
"@flakemetry/eslint-config": "workspace:*",
"@flakemetry/tsconfig": "workspace:*",
"@flakemetry/tsup-config": "workspace:*"
"@flakemetry/tsup-config": "workspace:*",
"@types/node": "^22.10.5",
"vitest": "^3.2.4"
},
"dependencies": {
"@flakemetry/contracts": "workspace:*",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/resources": "^2.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.28.0"
}
}
150 changes: 150 additions & 0 deletions packages/sdk/src/__tests__/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base'
import { describe, expect, it } from 'vitest'

import { IngestClient } from '../client'
import { RESOURCE_ATTR, SPAN_ATTR, SPAN_NAMES } from '../conventions'
import { computeFingerprint, hashParams, normalizeFilePath } from '../fingerprint'
import { type RunContext, TestRunRecorder } from '../recorder'
import { emitRunSpans } from '../spans'

const context: RunContext = {
project: 'acme/web',
commitSha: 'a1b2c3d',
branch: 'main',
ciProvider: 'github_actions',
trigger: 'push',
ciRunId: '9000001',
}

const makeRecorder = () => {
const recorder = new TestRunRecorder(context)
recorder.startRun(new Date('2026-07-16T10:00:00Z'))
recorder.record({
filePath: 'e2e/auth/login.spec.ts',
suite: 'auth',
title: 'logs in',
status: 'fail',
attempt: 1,
startedAt: new Date('2026-07-16T10:00:01Z'),
durationMs: 1800,
error: { type: 'TimeoutError', message: 'Timeout 30000ms exceeded', stack: 'at login:12' },
})
recorder.record({
filePath: 'e2e/auth/login.spec.ts',
suite: 'auth',
title: 'logs in',
status: 'flaky',
attempt: 2,
retryOfIndex: 0,
startedAt: new Date('2026-07-16T10:00:03Z'),
durationMs: 1400,
})
recorder.finishRun('failed', new Date('2026-07-16T10:00:05Z'))
return recorder
}

describe('fingerprint', () => {
it('normalizes paths so os and prefix differences do not fork identity', () => {
expect(normalizeFilePath('.\\E2E\\Auth\\Login.spec.ts')).toBe('e2e/auth/login.spec.ts')
})

it('is stable for the same test and distinct for a different title', () => {
const a = computeFingerprint({ filePath: 'a.spec.ts', suite: 's', title: 'does x' })
const b = computeFingerprint({ filePath: 'a.spec.ts', suite: 's', title: 'does x' })
const c = computeFingerprint({ filePath: 'a.spec.ts', suite: 's', title: 'does y' })
expect(a).toBe(b)
expect(a).not.toBe(c)
expect(a.startsWith('sha256:')).toBe(true)
})

it('hashes params order-independently', () => {
expect(hashParams({ b: 2, a: 1 })).toBe(hashParams({ a: 1, b: 2 }))
expect(hashParams({})).toBeNull()
expect(hashParams(null)).toBeNull()
})
})

describe('recorder to ingest batch', () => {
it('builds a contract-valid batch with computed fingerprints and retry linkage', () => {
const batch = makeRecorder().toIngestBatch('gh-9000001-1')
expect(batch.executions).toHaveLength(2)
expect(batch.resource.commitSha).toBe('a1b2c3d')
expect(batch.run.status).toBe('failed')
expect(batch.executions[1]?.retryOfIndex).toBe(0)
})

it('assigns the same identity to both attempts of the same test', () => {
const recorder = makeRecorder()
const [first, second] = recorder.recorded
expect(first?.fingerprint).toBe(second?.fingerprint)
})
})

describe('otel spans', () => {
it('emits a run span parenting one case span per attempt with convention attributes', () => {
const exporter = new InMemorySpanExporter()
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
})
const tracer = provider.getTracer('test')

emitRunSpans(tracer, makeRecorder())

const spans = exporter.getFinishedSpans()
const runSpan = spans.find((s) => s.name === SPAN_NAMES.run)
const caseSpans = spans.filter((s) => s.name === SPAN_NAMES.case)

expect(runSpan).toBeDefined()
expect(caseSpans).toHaveLength(2)
expect(runSpan?.attributes[RESOURCE_ATTR.project]).toBe('acme/web')
expect(caseSpans[0]?.attributes[SPAN_ATTR.suite]).toBe('auth')
expect(caseSpans[0]?.attributes[SPAN_ATTR.fingerprint]).toContain('sha256:')
expect(
caseSpans.every((s) => s.parentSpanContext?.spanId === runSpan?.spanContext().spanId),
).toBe(true)
expect(caseSpans[0]?.events[0]?.name).toBe('exception')
})
})

describe('ingest client', () => {
it('sends the batch with auth and idempotency headers and parses the ack', async () => {
let seen: { url: string; headers: Record<string, string>; body: string } | null = null
const fetchImpl = (async (url: string, init: RequestInit) => {
seen = {
url,
headers: init.headers as Record<string, string>,
body: init.body as string,
}
return new Response(JSON.stringify({ receiptId: 'r1', acceptedExecutions: 2 }), {
status: 202,
})
}) as unknown as typeof fetch

const client = new IngestClient({
endpoint: 'https://ingest.test/',
token: 'fmk_secret',
fetchImpl,
})
const result = await client.send(makeRecorder().toIngestBatch('gh-9000001-1'))

expect(result.ok).toBe(true)
expect(result.ack?.acceptedExecutions).toBe(2)
expect(seen!.url).toBe('https://ingest.test/v1/ingest')
expect(seen!.headers.authorization).toBe('Bearer fmk_secret')
expect(seen!.headers['idempotency-key']).toBe('gh-9000001-1')
})

it('fails open on a network error instead of throwing', async () => {
const fetchImpl = (async () => {
throw new Error('ECONNREFUSED')
}) as unknown as typeof fetch
const client = new IngestClient({ endpoint: 'https://ingest.test', token: 't', fetchImpl })
const result = await client.send(makeRecorder().toIngestBatch('k12345678'))
expect(result.ok).toBe(false)
expect(result.error).toContain('ECONNREFUSED')
})
})
75 changes: 75 additions & 0 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'

import { type IngestAck, ingestAckSchema, type IngestRunBatch } from '@flakemetry/contracts'

export interface IngestClientOptions {
endpoint: string
token: string
fetchImpl?: typeof fetch
bufferDir?: string | null
now?: () => number
}

export interface IngestResult {
ok: boolean
status?: number
ack?: IngestAck
buffered?: boolean
error?: string
}

export const INGEST_PATH = '/v1/ingest'

export class IngestClient {
private readonly endpoint: string
private readonly token: string
private readonly fetchImpl: typeof fetch
private readonly bufferDir: string | null
private readonly now: () => number

constructor(options: IngestClientOptions) {
this.endpoint = options.endpoint.replace(/\/+$/, '')
this.token = options.token
this.fetchImpl = options.fetchImpl ?? globalThis.fetch
this.bufferDir = options.bufferDir ?? null
this.now = options.now ?? (() => 0)
}

private buffer(batch: IngestRunBatch): boolean {
if (!this.bufferDir) return false
try {
mkdirSync(this.bufferDir, { recursive: true })
const file = join(this.bufferDir, `${batch.idempotencyKey}-${this.now()}.json`)
writeFileSync(file, JSON.stringify(batch))
return true
} catch {
return false
}
}

async send(batch: IngestRunBatch): Promise<IngestResult> {
try {
const response = await this.fetchImpl(`${this.endpoint}${INGEST_PATH}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${this.token}`,
'idempotency-key': batch.idempotencyKey,
},
body: JSON.stringify(batch),
})

if (!response.ok) {
const buffered = this.buffer(batch)
return { ok: false, status: response.status, buffered }
}

const ack = ingestAckSchema.parse(await response.json())
return { ok: true, status: response.status, ack }
} catch (error) {
const buffered = this.buffer(batch)
return { ok: false, buffered, error: error instanceof Error ? error.message : String(error) }
}
}
}
32 changes: 32 additions & 0 deletions packages/sdk/src/conventions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export const SPAN_NAMES = {
run: 'test.run',
case: 'test.case',
step: 'test.step',
} as const

export const RESOURCE_ATTR = {
serviceName: 'service.name',
project: 'flakemetry.project',
ciProvider: 'ci.provider',
ciRunId: 'ci.run_id',
commitSha: 'vcs.commit_sha',
branch: 'vcs.branch',
prNumber: 'vcs.pr_number',
} as const

export const SPAN_ATTR = {
fingerprint: 'test.identity.fingerprint',
suite: 'test.suite',
title: 'test.title',
paramsHash: 'test.params_hash',
status: 'test.status',
attempt: 'test.attempt',
retryOf: 'test.retry_of',
filePath: 'test.file_path',
durationMs: 'test.duration_ms',
} as const

export const CONVENTIONS_VERSION = '0.1.0'

export type ResourceAttributeKey = (typeof RESOURCE_ATTR)[keyof typeof RESOURCE_ATTR]
export type SpanAttributeKey = (typeof SPAN_ATTR)[keyof typeof SPAN_ATTR]
29 changes: 29 additions & 0 deletions packages/sdk/src/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createHash } from 'node:crypto'

import type { JsonRecord } from '@flakemetry/contracts'

export const normalizeFilePath = (filePath: string): string =>
filePath.replaceAll('\\', '/').replace(/^\.\//, '').replace(/^\/+/, '').toLowerCase()

export const hashParams = (params: JsonRecord | null | undefined): string | null => {
if (!params || Object.keys(params).length === 0) return null
const canonical = JSON.stringify(params, Object.keys(params).sort())
return createHash('sha256').update(canonical).digest('hex').slice(0, 16)
}

export interface FingerprintInput {
filePath: string
suite: string
title: string
paramsHash?: string | null
}

export const computeFingerprint = (input: FingerprintInput): string => {
const parts = [
normalizeFilePath(input.filePath),
input.suite.trim(),
input.title.trim(),
input.paramsHash ?? '',
]
return `sha256:${createHash('sha256').update(parts.join(' ')).digest('hex')}`
}
11 changes: 5 additions & 6 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
export const PACKAGE = '@flakemetry/sdk' as const

export const sdk = {
name: PACKAGE,
version: '0.0.0',
} as const
export * from './client'
export * from './conventions'
export * from './fingerprint'
export * from './recorder'
export * from './spans'
Loading
Loading