This document is the single, authoritative place for the hard-won, non-obvious knowledge behind the @cap-js/telemetry test suite. It is meant for contributors: read it before adding or debugging a test. Test files keep only the rationale that is local to a specific test; anything general or repeated lives here.
npm test # vitest, sqlite in-memory (the default)
node_modules/.bin/vitest run # same, without the --silent from the npm script- Runner: Vitest. Config in
vitest.config.mjs. - Default database:
@cap-js/sqlite, in-memory. No external services are needed for the default run. - Test app: a small bookshop under
test/bookshop— CDS model, services, and test-only wiring (exporters/reader, ignore hooks). Each test spins it up withcds.test(__dirname + '/bookshop', ...). - CI matrix: Node 22 & 24 × cds 9 & 10 (see
.github/workflows/ci.yml). The lint job additionally runs ESLint andoxfmt --check.
Lint / format locally:
npx eslint . --max-warnings=0 # npm run lint
npx oxfmt --check # npm run format:checkThe suite runs on two databases, and the difference in DB isolation model drives most of the test infrastructure.
| sqlite (default / PR CI) | HANA (separate workflow) | |
|---|---|---|
| DB per test file | Own in-memory DB — each file is fully isolated | One shared HDI container across all files |
| File parallelism | Full parallelism | Serial (fileParallelism: false) |
| Timeouts | 42s test / 30s hook | 10× test timeout |
| Retries | 0 (deterministic) | retry: 2 (self-heal unlucky timing) |
| Outbox bleed | Impossible (fresh DB) | Must be actively prevented (see below) |
Because HANA reuses one container for the whole run, a background queue/outbox worker from one file can still be draining when the next file starts and would dispatch leftover rows — adding foreign cds.spawn - run task root spans that break exact root-count assertions. The queue/outbox test files therefore:
- clear the outbox in
beforeEach(before resetting the span buffer, so theDELETE's own spans aren't captured), and - settle in
afterAll: clear, wait for the last worker iteration, clear again — every clear timeout-bounded viaclearOutboxso a draining pool can't hang the hook.
All of this is a no-op on sqlite (fresh in-memory DB per file), gated on the HANA signal.
- The HANA job runs when
process.env.CI && process.env.HANA_DRIVERare set. On that pathvitest.config.mjsraises the timeout, disables file parallelism, enables retries, and excludes the multitenancy suites (see Sanctioned skips). - It also sets
process.env.TELEMETRY_TEST_HANA = '1'in the config module. Test files that must branch at collection time (beforecds.test()applies its--profile) read this env var rather thancds.env: readingcds.envthat early would freeze the env singleton before the profile is applied, so the tracer provider would be built with the wrong exporter and no spans would be captured. - HANA runs from its own workflow,
.github/workflows/hana.yml—workflow_dispatchonly, against a protectedhanaenvironment with a pre-provisioned HDI container. It is not part of the PR CI.
Test configuration lives in test/bookshop/.cdsrc.json as cds config profiles, selected per test file:
cds.test(dir, '--profile', 'tracing-in-memory') // one profile
cds.test(dir, '--profile', 'metrics-outbox, multitenancy') // profiles compose| Profile | What it does |
|---|---|
[logging] |
Disables the tracing exporter (false) so no outbox-scan trace primer leaks into the console spy; wires a ConsoleLogRecordExporter + custom processor; sets log.format: json and cls_custom_fields: ['foo']. |
[metrics] |
Wires MyInMemoryMetricReader; short exportIntervalMillis (100). |
[metrics-outbox] |
Enables the queue (_queue: true) + in-memory reader; exportIntervalMillis 1000 (leaves the shared HANA worker DB headroom). |
[metrics-outbox-disabled] |
Queue metrics off (_queue: false) — asserts no queue.* datapoints are ever exported. |
[tracing-in-memory] |
Wires MyInMemorySpanExporter as the trace exporter. |
[sampler-ignore-authors] |
Adds /odata/v4/admin/Authors to the sampler's ignoreIncomingPaths. |
[native-fetch] |
remote.native_fetch = true — routes outbound remote calls through native fetch (undici instrumentation) instead of the Cloud SDK. |
[no-scheduling] |
requires.scheduling: false — disables cds 10's default periodic outbox reads (they cause spurious passport set/reset pairs). |
[persistent-outbox] |
file-based messaging with a persistent outbox. |
[inboxed] |
file-based messaging with inboxed: true (producer- and consumer-side queue workers). |
[without-outbox] |
file-based messaging with outboxed: false (writes to the file directly from the producer tx). |
The
[multitenancy]profile lives in the app's owntest/bookshop/package.json(auth users +multitenancy: true), composed with the above where needed.
package.json cds config is loaded after .cdsrc.json (last-writer-wins). So any base default that a profile must be able to override has to live in the .cdsrc.json base, not in package.json — otherwise package.json would clobber the profile. #486 moved the base cds.log / messaging defaults into .cdsrc.json for exactly this reason.
Do not reintroduce
process.env.cds_*string-JSON config. That fragile pattern (config via stringified JSON in env vars, order-sensitive against the@sap/cdsrequire) was removed in #486. Use profiles.
Two exporter-shaped classes capture telemetry into module-level arrays that tests import directly — asserting on structured spans/datapoints, never scraping console.dir output:
test/bookshop/lib/MyInMemorySpanExporter.js— spans accumulate incaptured; helpersgroupedByTrace()/rootSpans()/reset(). Wired via thetracing-in-memoryprofile.test/bookshop/lib/MyInMemoryMetricReader.js— metrics captured via the metrics profiles. It honors DELTA temporality, matching production (lib/metrics/index.jsconfigures the real exporter withAggregationTemporality.DELTA), so the tests validate the real export shape. Under DELTA, counter datapoints report only the increment since the last collection, so the reader folds SUM increments into running totals while GAUGE datapoints keep their latest absolute value.
KEY RULE: neither module may require('@sap/cds') at module top. Doing so once broke span capture — the cds require has to happen inside the test file, after the profile is applied. Both modules stay dependency-light (only @opentelemetry/* primitives + node timers).
Cross-file correctness of the metric reader's process-level singletons relies on Vitest isolating each file in its own worker (pool: 'forks', isolate: true); two files sharing the module in one process would bleed counter totals together.
Centralized in test/utils.js (added in #488) so the ~10 tracing/metrics suites stop copy-pasting them. See the doc comments at each definition for full detail:
flushSpans()— force-flush the tracer provider's span processor so buffered spans reachcaptured.eventually(fn, { flush, timeout, interval })— state-based wait: repeatedly flush + re-run the assertion until it holds or times out. Replaces fixedwait(...)sleeps that flake on HANA (background/spawned work flushes after any reasonable fixed window).flushdefaults toflushSpans.makeExpectEventually(flush, { timeout, interval })— builds anexpectEventually(assertion)bound to a specific flush target + poll defaults (metric suites pass the reader'sforceFlush).clearOutbox(timeout)— best-effort, timeout-bounded outboxDELETEthat can never hang the surrounding hook (a draining HANA pool could otherwise block indefinitely).asExternalClient(fn)— runs a client request undersuppressTracing. The in-process test client would otherwise create an outgoing CLIENT span for every request (an artificial extra root that also overwrites any manually-settraceparent). Real callers are separate, un-instrumented processes; this models that so the incoming SERVER span is created normally and stays the trace root.isOutboxScanTrace(g)/meaningful(groups)— filter out the queue scheduler's pure outbox-scan bookkeeping traces (adb - txroot touching onlycds.outbox.Messages) so exact root-count assertions stay stable on the shared HANA container.
The flush + poll pattern: instead of await wait(500) then asserting, wrap assertions in eventually/expectEventually — it flushes, checks, and returns the instant the state holds (fast on sqlite, resilient to HANA's variable worker latency).
HTTP instrumentation is enabled in the test app. Consequences the tests rely on:
- Incoming requests produce a SERVER span that becomes each request trace's root; existing
<service> - txspans reparent under it (reparenting). The SERVER span also adopts the W3C trace context from an incomingtraceparentheader. - Outgoing requests produce CLIENT spans.
Because the test HTTP client runs in-process, its outgoing requests would themselves create CLIENT-span roots and pollute the trace. Tests therefore wrap client requests in asExternalClient (see above) to model an external, un-instrumented caller.
Only two skips are allowed (per #477). Any new skip must be justified against this bar; everything else that is skipped is tracked debt.
- SAP Passport —
test/passport.test.jsskips on sqlite (db.kind === 'sqlite'). SAP Passport is a HANA session-context feature with no sqlite equivalent; it runs on HANA. - Multitenancy on HANA —
tracing-mt.test.jsandmetrics-outbox-multitenant.test.jsare excluded from the HANA job (invitest.config.mjs). MTX tenant subscription needs a bound BTP Service Manager to provision per-tenant HDI containers, which the single pre-provisioned HDI container in CI lacks. They run fully on sqlite (in-memory tenants).
This is the inverse pairing: passport is sqlite-skip / HANA-run; multitenancy is HANA-skip / sqlite-run.
Other skips are debt tracked in #477, not sanctioned exceptions:
- §1 — queue-worker tracing on sqlite:
tracing-scheduled,tracing-outboxed-batch,tracing-messaging-inboxed,tracing-messaging-persistent-outboxskip their worker-span cases on sqlite. Published@sap/cdsuses a rawsetTimeoutbypass (notcds.spawn) for the sqlite queue worker to avoid a single-writer deadlock, so thecds.spawn - run taskroot span never appears. Gated on a cds queue-spawn fix landing; remove with a follow-up. - §3 — unimplemented stubs: placeholder
test.skipcases intracing.test.jsandtracing-mt.test.js(individual handlers, remote,$batch,srv.emit,cds.spawnunder multitenancy) — real coverage gaps to be written.
-
startup > NO_TELEMETRY=truelocal artifact.test/startup.test.jsshells out tocds servewith env overrides. In some local shells theNO_TELEMETRY=truecase can fail due to inherited environment; it passes in CI and in a clean environment. This is a pre-existing local-env artifact, not a product bug. -
Internal-registry lockfile trap for
@sap/*installs. Installing@sap/*packages against SAP's internal registry can rewritepackage-lock.jsonto internal URLs. Always install against the public npm registry and verify the lockfile is clean before committing:grep -c int.repositories.cloud.sap package-lock.json # must be 0