Skip to content

[SDTEST-3770] New exporter pipeline (OTel-native spans, logs, coverage)#258

Merged
ypopovych merged 26 commits into
mainfrom
SDTEST-3770-new-exporter-pipeline
May 25, 2026
Merged

[SDTEST-3770] New exporter pipeline (OTel-native spans, logs, coverage)#258
ypopovych merged 26 commits into
mainfrom
SDTEST-3770-new-exporter-pipeline

Conversation

@ypopovych

@ypopovych ypopovych commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Ports the EventsExporter / DatadogSDKTesting data pipelines onto the typed async API wrappers (SDTEST-3768) and the OTel-native exporter protocols. After this stack the SDK only feeds the upload pipeline with SpanData, ReadableLogRecord, and CoverageRecord — there are no direct exportEvent / exportLogs(fromSpan:) calls left, and the coverage feature follows the same OTel-style provider/processor/exporter split that spans and logs already use.

Sequence of commits (in order):

  • c69c1d9 — port file/upload pipeline to the new API wrappers (typed ClosureDataUploader + FeatureStoreAndUpload, JSONFileHeader-driven DataFormat, headerful files, sync drain on flush). RequestBuilder / MultipartFormDataRequest deleted.
  • 9dac658SpansExporter: OpenTelemetrySdk.SpanExporter, LogsExporter: LogRecordExporter. Encoders read service / version / environment / SDK info from SpanData.resource. DDTracer builds a Resource from Config / Environment / tracerVersion.
  • 2aabc89 — record-based coverage path (CoverageRecord + export(coverageRecords:)); the URL-driven export(coverage:parser:…) entry is gone.
  • 9bc7b77 — in-process pipeline integration test (spans + logs + coverage round-trip against MockBackend).
  • 3867747 — full-SDK integration tests: child XCTest + Swift Testing test classes, outer runner asserts spans / stdout-log / OTel-log / coverage payloads on the wire.
  • 46f35ea — sessions / modules / suites become real OTel spans, SpanEventsLogExporterAdapter routes span events as logs via MultiSpanExporter. exportEvent and exportLogs(fromSpan:) removed from the public surface.
  • e30cd13 — integration tests strengthened: dd.trace_id / dd.span_id on each log entry match the test span, coverage payload looked up by spanId == testSpan.spanId.
  • 6e341c6DD_CIVISIBILITY_LOGS_ENABLED=true in the integration-test harness so stdout capture + LoggerProvider are actually wired in the child SDK.
  • 753f798 — stdout / stderr captures emit OTel LogRecords through the registered LoggerProvider instead of attaching to the active span.
  • 70a1966 — capture tests intercept with OTel SDK's InMemoryLogRecordExporter; DDTracer(logRecordExporter:) lets tests plug in custom exporters.
  • ac76e7c — coverage architecture rework: new CoverageProcessor protocol (in EventsExporter), BackgroundCoverageProcessor impl (in DatadogSDKTesting, owns the OperationQueue), CodeCoverageProvider (TracerSdk-shaped), CoverageRecordCoverageData rename + new URL-based CoverageRecord, CodeCoverageParser dependency removed from EventsExporter target.
  • d05d627 — isolate CoverageExporterTests from shared Caches state so the suite no longer flakes when other tests share a process.
  • de43c5f — collapse DDTestSessionSpan / DDTestModuleSpan / DDTestSuiteSpan and the CITestEnvelope test-span path into a single TestSpan with a SpanType discriminator; DDSpan is generic-only. Events now write the inner type field (matches .NET wire format) and strip resource / type / version from per-event meta; environment / git / CI / runtime metadata flows through SpanMetadata instead. Test models expose a unified attributes: [String: TestAttributeValue] with get(tag:) / get(metric:) in place of separate tags / metrics dicts.
  • a10110b — gitignore .claude/ agent state.
  • 8a7483a — fix integration smoke suites for the new wire shape: assert on span.resource (top-level field) instead of meta[DDGenericTags.resource].
  • 96295b9 — forward DD_CIVISIBILITY_LOGS_ENABLED and DD_CIVISIBILITY_CODE_COVERAGE_ENABLED from the runner to the child test bundle via the xctestplan env-var allowlist (without this the runner's config.environment overrides never reached the child SDK).
  • 3934f69 — split CodeCoverage out of TestImpactAnalysis into its own TestHooksFeature + factory with an independent isEnabled gate (config.codeCoverageEnabled && remote.itr.codeCoverage); coverage now runs in its own DDTestMonitor.coverageSetup block and no longer requires ITR/TIA. Also fixes MockBackend settings / known-tests / test-management responses to echo the request's JSON:API data.id so the SDK accepts them (hardcoded "id": "1" was failing the SDK's idMismatch check and cascading "backend config can't be loaded" through every feature).
  • 08ca963 — exercise the no-TIA coverage path: stdoutOTelAndCoverage integration tests leave backend.settings.itrEnabled at its default (false) and only flip on codeCoverage.
  • 56ac468 — tighten JSON:API response validation: new APIResponseAttributesHasType / HasId / BrokenId / NoId protocol family lets each endpoint declare its actual id/type shape, replacing the global id == self.id check that didn't fit known-tests / test-management (broken id) or TIA (no id). Also lifts Content-Encoding: deflate off the global default headers and onto the per-API services (spans, logs, telemetry, TIA coverage, git pack uploads), with the HTTPClient compressing the body when the header is set. Knock-on: MockBackend.route(...) now feeds the inflated body into parseCoveragePayload so /api/v2/citestcov works under compression.
  • 4a0ea93SpansExporter / LogsExporter / CoverageExporter take a storage: Directory parameter (EventsExporter.init(config:, storage:) fans it out as spans/, logs/, coverage/ subdirs) instead of constructing a hardcoded com.datadog.civisibility/.../v1 path under /Library/Caches. Production wires storage from cacheManager.session(feature: "exporter") so exporter files now live under the per-session directory; tests pass a per-case Directory.temporary().createSubdirectory(path: UUID().uuidString). CoverageExporterTests loses its setUp/tearDown wipe (no shared path); LibraryConfigurationServiceThrowTests gains a makeExporter(baseURL:) helper that tracks and cleans up storages.
  • 6e43d19 — make HTTPClientType async-only. Protocol now declares just send(request:) and sendWithResponse(request:) returning the response / body with typed HTTPClient.RequestError throws; the callback variants and runAsync helper are deleted. HTTPClient performs each request through a shared perform(_:_:) that wraps URLSession.dataTask in a withCheckedContinuation. MockHTTPClient mirrors the new signatures, MockClosureDataUploader bridges to async via waitForAsync, and HTTPClientTests converts to async throws (@MainActor to satisfy ServerMock's main-thread precondition).
  • a8fae60 — fix git upload + API response-shape corner cases. GitUploader moves its "already-uploaded" sentinel check from init into sendGitInfo(...) (returning true instead of failing construction), and trailing-slashes the packfile temp directory path so git pack-objects writes into it. GitUploadApi.uploadPackFiles drains the entire withThrowingTaskGroup (while try await group.next() != nil {}) instead of returning after the first child task — otherwise the remaining uploads were cancelled. GitUploadApi.Commit switches to APIResponseAttributesNoId (the packfile commit endpoint doesn't echo an id), and Array picks up APIAttributesNoId / APIResponseAttributesNoId so collection responses get the no-id rule for free. Adds APIResponseAttributesIgnoreType as a sibling to HasType for future endpoints that don't gate on the response type, and reorders the SettingsResponse conformances to HasType, HasId for consistency.
  • 367765dMultipartFormURLRequest.append(data:withName:contentType:) makes the fileName: argument optional (was required filename: String); when absent, the Content-Disposition header omits the filename="…" attribute. Callers that don't actually represent a file drop it: TestImpactAnalysisApi.uploadCoverage(...) no longer attaches the bogus CoverageBatch.json / DummyEvent.json filenames to the coverage / event parts; GitUploadApi.uploadPackFile(...) drops it from the JSON pushedSha part (kept on the binary packfile part).
  • 1f063e8 — JSON-shaped debug descriptions for API request/response types. APICommonData refines CustomDebugStringConvertible with a default impl that renders the wrapper as {"type": "<apiType>", "id": "<id>", "attributes": <attributes>} (omitting id when nil and attributes when APIVoidValue); APIEnvelope mirrors with {"meta": …, "data": …}. The per-service Attribute types (Settings request + response with nested EFD / TestManagement, KnownTests TestsRequest / TestsResponse with PageInfo halves, TestManagement TestsRequest / TestsResponse, TIA TestsRequest / TestsResponse with Meta, GitUpload's Commit / CommitRequestMeta) conform to CustomDebugStringConvertible with hand-written JSON-ish renderings — string fields quoted, optionals print null, and nested [String: JSONGeneric] payloads reuse JSONGeneric.object(...).debugDescription. GitUploadApi.searchCommits switches its log format from [meta: …, data: …] to the new envelope shape.
  • b8f436c — drop Xcode_26.1 from the unit-tests and integration-tests CI matrices (toolchain image is currently broken on GitHub-hosted macos-26 runners) and move the release workflow's XCODE_VERSION from 26.1 to 26.2.

Test plan

  • xcodebuild -scheme EventsExporter test — green locally (103 cases).
  • xcodebuild -scheme DatadogSDKTesting test — green locally; covers refactored encoders, SpanEventsLogTests / TestSpanTests, CoverageExporterTests, CoverageBitmapTests, StdoutCaptureTests / StderrCaptureTests (now using InMemoryLogRecordExporter), TestImpactAnalysisTests / TestImpactAnalysisSwiftTestingTests (TIA split from coverage), DDTracerTests.testLogStringAppUI.
  • xcodebuild -scheme IntegrationTests-UnitTests build-for-testing — child target builds clean for macosx,arch=arm64.
  • Full IntegrationTests suite (XCTest + Swift Testing smoke, 20/20 green locally), including the new stdoutOTelAndCoverage cases in UnitTests+XCTestSmoke.swift and UnitTests+SwiftTestingSmoke.swift — the latter now runs with ITR/TIA off to verify coverage works standalone.

🤖 Generated with Claude Code

ypopovych and others added 11 commits May 18, 2026 14:14
Rewire SpansExporter / LogsExporter / CoverageExporter to drive the
typed test-optimization API services (SpansApi / LogsApi /
TestImpactAnalysisApi) instead of the legacy RequestBuilder +
DataUploader stack. The exporters' public surface is unchanged —
EventsExporter still exposes exportEvent, the URL-based coverage entry,
the configuration-API facade, and SpanExporter conformance, so no
consumer in DatadogSDKTesting had to move.

Internals:
- DataFormat gains DataFormatType and a JSONFileHeader-driven init that
  builds a `{...header..., "<batch>": [...]}` envelope around each
  feature's entries.
- FilesOrchestrator gains FilesOrchestratorType; getWritableFile now
  returns (file, isNew) so FileWriter can emit `prefix` on a fresh file
  and `separator` on reuse. New closeWritableFile()/getAllReadableFiles()
  back the synchronous flush path.
- FileWriter takes an injected encoder + entity label, supports
  update(dataFormat:) and closeCurrentFile() (under barrier); writeSync
  throws.
- FileReader: readNextBatch -> getNextBatch() throws; markBatchAsRead
  throws; new getRemainingBatches() returns a Batch.Iterator.
- DataUploader -> ClosureDataUploader: adapts (Data) async
  throws(HTTPClient.RequestError) -> Void to DataUploadStatus, driven
  through the existing waitForAsync shim.
- DataUploadWorker: extract DataUploadWorkerType (update / flush /
  stop); flush() synchronously drains pending batches with retry,
  honoring server Retry-After via Delay.set(delay:); shutdown is now
  stop().
- DataUploadStatus gains waitTime + an (httpCode:headers:) init that
  parses Retry-After / X-RateLimit-Reset.
- FeatureStorage + FeatureUpload collapse into FeatureStoreAndUpload.
- TestImpactAnalysisApi gains uploadCoverage(batch:) (multipart POST
  to /api/v2/citestcov).
- Deleted Upload/RequestBuilder.swift + Upload/MultipartFormDataRequest.swift
  (and their Xcode project entries).
- Consolidated on JSONEncoder.apiEncoder: outputFormatting gains
  .withoutEscapingSlashes so existing URL/string serialization stays
  byte-for-byte; JSONEncoder.default() factory removed.
- APIServiceConfig gains init(configuration:) so sub-exporter tests
  can build a SpansApiService/LogsApiService directly without
  duplicating the field mapping in EventsExporter.

Tests:
- Persistence/Upload/Spans/Logs tests updated for the new init shapes
  and method names; new DataFormat.formatFileContents(_:) test helper
  lets FileReaderTests seed a file with prefix-included contents and
  assert the read result preserves the prefix.
- MockClosureDataUploader replaces the old DataUploader-backed mock
  in the upload worker/uploader tests, keeping MockHTTPClient as the
  request recorder.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
… Resource

Phase 2 of the exporter refactor. The data path now follows the OTel
contract end-to-end: the SDK delivers SpanData / ReadableLogRecord
through the protocols, and the per-feature exporters read service /
version / environment / SDK info from `SpanData.resource` instead of
carrying them as init args. EventsExporter's facade (exportEvent,
SpanExporter conformance, the configuration-API methods) is unchanged.

Internals:
- New OpenTelemetry+Extensions.swift: typed accessors on `Resource`
  (`service`, `applicationName`, `applicationVersion`, `environment`,
  `sdkName`, `sdkLanguage`, `sdkVersion`) backed by `SemanticConventions`,
  `SpanData.testSessionId/SuiteId/ModuleId`, and `ResultCodeCompatible
  &&` overloads on `SpanExporterResultCode` / `ExportResult`.
- `DDSpan(spanData:)` drops the `serviceName` / `applicationVersion`
  arguments and reads them off `spanData.resource`.
- `DDLog` gains an init taking `(spanId, traceId, timestamp, status,
  message, resource, attributes)`; `DDLog(event:span:)` and a new
  `DDLog(log: ReadableLogRecord, span: SpanContext)` use it. The
  legacy `DDLog(event:span:configuration:)` is gone. `Status` gains a
  `init(severity: Severity)` bridge.
- `SpansExporter` conforms to `OpenTelemetrySdk.SpanExporter`. Adds
  `setMetadata(_:)` which rebuilds the headerful `DataFormat` (keeping
  the per-runtime `runtime-id` pinned) and rotates the writable file
  via `FeatureStoreAndUpload.update(dataFormat:)`.
- `LogsExporter` conforms to `OpenTelemetrySdk.LogRecordExporter`,
  exposing `export(logRecords:) -> ExportResult`, `forceFlush`, and
  `shutdown(timeout:)`. The stored `configuration` field is gone.
- New `SpanEventsLogExporterAdapter`: a `SpanExporter` that forwards
  each span's events to `LogsExporter.exportLogs(fromSpan:)`. Wrapped
  with the real `SpansExporter` inside an
  `OpenTelemetrySdk.MultiSpanExporter`, so calling
  `EventsExporter.export(spans:)` lands both spans on the spans
  pipeline and span events on the logs pipeline transparently.
- `EventsExporter` exposes `setMetadata(_:)` on the protocol; it
  forwards to `spansExporter.setMetadata` (the only feature whose
  file header embeds metadata).
- `DDTracer` builds a `Resource` populated from `Config` / `Environment`
  / `DDTestMonitor.tracerVersion` and passes it to
  `TracerProviderBuilder.with(resource:)` so the new resource-reads in
  the encoders see real values. Inner `DDTracer.init` gains an optional
  `resource` parameter, defaulting to an empty `Resource()` for
  existing call sites that don't supply one (e.g. tests).

Tests:
- New `SpanCompositesTests` covering `SpanEventsLogExporterAdapter`
  end-to-end against `MockBackend`.
- `SpanSanitizerTests` updated for the simplified `DDSpan(spanData:)`
  initializer.
- Pre-existing `EventsExporterTests` already covers the
  `MultiSpanExporter`-driven span-and-logs flow and passes unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Phase 3 of the exporter refactor. The coverage pipeline now takes
pre-parsed `CoverageRecord`s instead of a (URL, parser) pair, matching
the OTel-style boundary used by the spans/logs exporters: the caller
owns the profraw lifecycle, parses it, and hands the exporter a typed
record carrying the OTel anchors and an explicit `Context` (test vs.
suite).

Internals:
- `CoverageRecord` (public) carries `name`, parsed `CoverageInfo`,
  `workspacePath`, `Resource`, `InstrumentationScopeInfo`, and a
  `Context` enum (`.test(testSpanId:, suiteId:, sessionId:)` /
  `.suite(suiteSpanId:, sessionId:)`). Convenience accessors expose
  `sessionId`, `suiteId`, `testId`, and `isSuite`.
- `CoverageExporterType` (public) — `export(coverageRecords:,
  explicitTimeout:) -> ExportResult`, `forceFlush(explicitTimeout:)`,
  `shutdown(explicitTimeout:)`, plus no-arg convenience overloads.
  `CoverageExporter` conforms; its old URL-based
  `exportCoverage(coverage:parser:...)` entry point is gone. The
  exporter still translates each record into a `TestCodeCoverage` on
  disk (suite-level records serialize `span_id: 0`).
- `EventsExporterProtocol` swaps `export(coverage:parser:...)` for
  `export(coverageRecords:)`. `EventsExporter` no longer imports
  `CodeCoverageParser`.

Consumer side:
- `DDCoverageHelper.endTest` switches its signature from raw `UInt64`s
  to `SpanId`s. The helper now owns the full per-test flow: it parses
  the profraw via `CoverageProcessor.filesCovered(in:)`, builds a
  `.test` `CoverageRecord`, calls `exporter.export(coverageRecords:)`,
  and applies the debug-save / delete decision based on
  `configuration.debug.saveCodeCoverageFiles`.
- `TestImpactAnalysis.testDidFinish` passes `SpanId` values straight
  through (no more `.rawValue` unwrapping).
- `Mocks.CoverageCollector` in the test mocks adopts the new
  `TestCoverageCollector` signature; the existing UInt64-keyed `has`
  helper keeps the same shape for the assertions that still use it.

Tests:
- New `CoverageExporterTests` covering both the test-level and
  suite-level record paths end-to-end against `MockBackend`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
PipelineIntegrationTests drives all three exporter paths in a single
test against MockBackend and asserts the wire payloads:

- A span carrying a stdout-capture-shaped event ("logString" + a
  "message" attribute) — exercises the SpanEventsLogExporterAdapter
  that converts span events into log entries.
- A native OTel LogRecord emitted via a LoggerProvider whose
  SimpleLogRecordProcessor is wired to LogsExporter — exercises
  LogsExporter.export(logRecords:) (severity -> DDLog.Status, body ->
  message, span context -> dd.trace_id / dd.span_id).
- A CoverageRecord submitted through
  EventsExporter.export(coverageRecords:) — exercises the
  CoverageRecord -> TestCodeCoverage conversion.

The test then verifies, per pipeline, that:
- The span's service / version / name come from the Resource.
- Both logs carry the same span context as the source span (correlation
  via dd.trace_id / dd.span_id) and pick up service / version from
  the same Resource.
- The OTel log's severity (`.warn`) maps to `status: warn` on the wire.
- The coverage payload carries the expected session / suite / span IDs.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
End-to-end coverage for the three telemetry pipelines the SDK now
ships: stdout capture -> span event -> SpanEventsLogExporterAdapter ->
LogsExporter, native OTel LogRecord -> LoggerProvider -> LogsExporter,
and per-test code coverage -> CoverageExporter.

OTel LogRecord plumbing:
- EventsExporter conforms to OpenTelemetrySdk.LogRecordExporter via
  extension; export(logRecords:) and forceFlush(explicitTimeout:) are
  forwarded to the wrapped LogsExporter and shutdown(explicitTimeout:)
  is shared with the SpanExporter conformance. EventsExporterProtocol
  inherits from both SpanExporter and LogRecordExporter.
- DDTracer.init builds a LoggerProviderSdk with the EventsExporter as
  the LogRecordExporter (NoopLogRecordExporter when traces are
  disabled), the same Resource as the TracerProvider, and a
  SimpleLogRecordProcessor; the provider is registered globally via
  OpenTelemetry.registerLoggerProvider, so any consumer can emit logs
  through `OpenTelemetry.instance.loggerProvider` and they will land in
  the same upload pipeline as the span-event-derived ones.

Integration tests:
- IntegrationTests-UnitTests links OpenTelemetryApi so the child test
  bundle can call OpenTelemetry directly.
- IntegrationTests-UnitTests.xctestplan enables LLVM code-coverage
  instrumentation; without it DDCoverageHelper has nothing to parse
  and the coverage pipeline never fires.
- IntegrationTests/UnitTests/XCTestSmokeTests.swift gains
  XCStdoutOTelAndCoverage, and SwiftTestingSmokeTests.swift gains
  STStdoutOTelAndCoverage. Each child test print()s a line (stdout
  pipeline), emits an OTel LogRecord at Severity.warn (OTel pipeline),
  and passes (coverage pipeline runs implicitly via
  DDCoverageHelper.endTest).
- IntegrationTests/Runner/UnitTests+XCTestSmoke.swift and
  UnitTests+SwiftTestingSmoke.swift gain
  `stdoutOTelAndCoverage` outer runner tests that:
    * Drive the child test under the real SDK in a subprocess, with
      MockBackend settings enabling itr/codeCoverage and the
      DD_CIVISIBILITY_CODE_COVERAGE_ENABLED env var on.
    * Assert one test span arrived (status pass, expected
      resource/name/suite).
    * Assert both log entries are present on the wire, and that the
      OTel-shaped one carries status `warn` (Severity.warn ->
      DDLog.Status.warn).
    * Assert at least one coverage payload arrived.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Closes the loop on the native OTel data path: the EventsExporter no
longer exposes `exportEvent` or `exportLogs(fromSpan:)`, and producers
hand the SDK either a `SpanData` (via SimpleSpanProcessor +
SpanExporter) or a `ReadableLogRecord` (via LogRecordExporter) — the
upload pipeline consumes only OTel-shaped inputs.

Spans path:
- Sessions / modules / suites no longer write pre-encoded
  `SessionEnvelope` / `ModuleEnvelope` / `SuiteEnvelope` JSON directly
  into the spans pipeline. Each emits an `OpenTelemetrySdk.SpanSdk`
  through a new `DDTracer.createLifecycleSpan(name:spanId:startTime:
  attributes:)` helper that wires `SpanSdk.startSpan` with the registered
  span processor / clock / resource and a `SpanContext` whose `spanId`
  is the lifecycle object's controlled id (preserving the
  `test_session_id` / `test_module_id` / `test_suite_id` semantics on
  the wire). The span flows through the registered
  `SimpleSpanProcessor → EventsExporter → MultiSpanExporter →
  SpansExporter` pipeline.
- `SpansExporter.exportSpan` switches on `span.attributes["type"]` and
  routes to per-type encoders: `test` -> `CITestEnvelope`,
  `test_session_end` -> new `TestSessionEnvelope`, `test_module_end` ->
  new `TestModuleEnvelope`, `test_suite_end` -> new
  `TestSuiteEnvelope`, otherwise `SpanEnvelope`.
- New `TestLifecycleEncoders.swift` adds `DDTestSessionSpan` /
  `DDTestModuleSpan` / `DDTestSuiteSpan` plus their envelopes. They
  read everything they need from `SpanData` (start, duration, status,
  attributes, name, resource) and reproduce today's
  Session/Module/Suite content byte-for-byte (no `trace_id` /
  `span_id` / `parent_id`, just `test_*_id` plus
  start/duration/error/name/resource/service/meta/metrics).
- `Session`, `Module`, `Suite` drop their `Encodable` conformance and
  the bespoke `encode(to:)` + `*Envelope` types.

Logs path:
- `SpanEventsLogExporterAdapter` no longer calls
  `LogsExporter.exportLogs(fromSpan:)`. It builds a
  `ReadableLogRecord` per `SpanData.Event` (carrying span context,
  resource, instrumentation scope, severity inferred from the
  optional `status` attribute, the event's attributes, and `eventName
  = event.name`) and hands the batch to the wrapped `LogRecordExporter`
  via `export(logRecords:)`. The adapter's init now takes a
  `LogRecordExporter` instead of a concrete `LogsExporter`.
- `LogsExporter.exportLogs(fromSpan:)` is removed.
  `DDLog(event:span:)` follows.
- `LogsExporterTests` is rewritten against `export(logRecords:)`.

`EventsExporterProtocol` loses `exportEvent`; the concrete
`EventsExporter` loses both that method and the
`SpanEventsLogExporterAdapter(logsExporter:)` initialiser. The
remaining public surface — `export(spans:)`, `export(logRecords:)`,
`export(coverageRecords:)`, `setMetadata`, the configuration-API
facade — is now uniformly typed against OTel inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The integration tests now verify the SDK linked the data correctly,
not just that each pipeline produced *something*:

- Capture the test span's trace_id / span_id / test_session_id /
  test_module_id / test_suite_id from `backend.allTestSpans`.
- For both logs (stdout and OTel), assert `dd.trace_id` and
  `dd.span_id` on the wire match the test span — confirming the
  SpanEventsLogExporterAdapter and LogRecordExporter pipelines both
  stitch span context onto the log records.
- For coverage, look up the payload by `spanId == testSpan.spanId`
  rather than blindly grabbing the first entry, then assert
  `testSessionId` / `testSuiteId` match the test span. Same
  expectations apply to the XCTest and Swift Testing flavors.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`Config.enableStdoutInstrumentation` (and the companion stderr flag)
default to `false`. They only flip on via `DD_CIVISIBILITY_LOGS_ENABLED`
(umbrella) or the per-stream `DD_ENABLE_STDOUT_INSTRUMENTATION` /
`DD_ENABLE_STDERR_INSTRUMENTATION` env vars. The integration test
harness didn't set any of them, so the child SDK never installed the
StdoutCapture hook — `print()` went out untouched and no
`logString` span event was attached to the test span. The OTel log
path (LoggerProvider) is governed by the same umbrella flag, so it
was also a no-op.

Add `DD_CIVISIBILITY_LOGS_ENABLED=true` to both flavor configs so the
child process actually installs the capture hook and the logger
provider for the duration of the test.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The capture hooks (`StdoutCapture` / `StderrCapture`) and the
test-error context path in `DDTest` used to attach their content to
the active test span via `span.addEvent(name: "logString", ...)`. The
`SpanEventsLogExporterAdapter` then turned those events into log
entries on flush. This added a layer of indirection — log payloads
piggy-backed on span data instead of being first-class telemetry.

`DDTracer` now owns a cached `LoggerSdk` built from the same
LoggerProvider it registers with `OpenTelemetry`, and the public
`logString` / `logError` / `logString(...timeIntervalSinceSpanStart:)`
APIs emit OTel `LogRecord`s directly:

- `setBody(.string(message))` — message goes out as the log body, no
  longer as a `"message"` attribute.
- `setSeverity(.info)` for stdout and step lines, `.error` for
  test-assertion error context.
- `setAttributes(testAttributes())` — keeps `test.name` /
  `test.suite` / `test.module` on the log record.
- `setTimestamp(...)` only when the caller provided one (e.g. the
  parsed system-log date or the UI-step offset).
- Trace context auto-injection (`includeTraceContext` defaults to
  true on `LoggerBuilderSdk`) attaches the active span's
  `dd.trace_id` / `dd.span_id` automatically.

The UI-test launch-context path (`logStringAppUITested`) no longer
synthesises an aux span; it now emits a `LogRecord` with the launch
`SpanContext` set explicitly via `setSpanContext(...)`.

The wire shape is unchanged — the OTel `LogRecord` flows through the
registered `SimpleLogRecordProcessor` to `LogsExporter`, which writes
the same `DDLog` JSON as before (body becomes the `message` field,
severity drives `status`, span context drives `dd.trace_id` /
`dd.span_id`). The `SpanEventsLogExporterAdapter` stays in place as a
general OTel convenience for consumers that attach events to their own
spans, but the SDK no longer drives it from internal capture.

Tests:
- `StdoutCaptureTests` / `StderrCaptureTests` assert the buffer is
  drained and that the active span has no events (the old assertion
  was on `spanData.events.count == 1`). Round-trip emission is
  exercised by the IntegrationTests' `stdoutOTelAndCoverage` cases.
- `DDTracerTests.testLogStringAppUI` flips from "an aux span must be
  emitted" to "no span must be emitted — the message must go out as a
  LogRecord" to lock in the new behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The capture tests previously asserted only that the span wasn't
mutated; they didn't verify the LogRecord was actually emitted. Plug
in OTel's own `InMemoryLogRecordExporter` so the tests can introspect
the wire payload directly.

- DDTracer gains an optional `logRecordExporter: LogRecordExporter?`
  init parameter (defaults nil → existing behaviour). When set, the
  internal `LoggerProviderSdk` wires that exporter via
  `SimpleLogRecordProcessor` instead of the EventsExporter /
  NoopLogRecordExporter pair. The convenience init threads it
  through too.
- `StdoutCaptureTests` / `StderrCaptureTests` install a DDTracer
  backed by an `InMemoryLogRecordExporter` as the global
  `DDTestMonitor.tracer` (which `StdoutCapture` / `StderrCapture`
  route through), drive `print()` / `StderrCapture.stderrMessage`,
  then assert on `getFinishedLogRecords()`:
    * body matches the captured text (system-log prefix stripped /
      newline preserved as appropriate)
    * severity is `.info`
    * `spanContext.spanId` / `traceId` correlate to the active test
      span — confirming the LoggerSdk's auto-injection of span
      context works end-to-end
    * for stderr the parsed system-log timestamp lands on
      `record.timestamp`
    * the empty-buffer-without-newline case produces zero records
  Original tracer is restored in `tearDown`.
- `DDTracerTests.testLogStringAppUI` flips from
  "no aux span is produced" to "no aux span AND the LogRecord
  carries the launch traceId/spanId from env" — verifies
  explicit `setSpanContext(launchSpanContext)` works.

`@testable import OpenTelemetrySdk` is needed because
`InMemoryLogRecordExporter()` is `public` on the class but its
synthesised init is `internal`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Mirror the spans / logs pipeline for coverage so the SDK side owns the
parser dependency and the exporter module stays parser-agnostic. Also
move all threading into the processor so the provider's API stays
synchronous.

Data shapes (EventsExporter):
- `CoverageContext` enum lifted out of the old `CoverageRecord.Context`
  and shared between the URL-stage record and the parsed payload.
- `CoverageFile { name: String, bitmap: Data }` is the wire-ready
  per-file entry; no `CoverageInfo` dependency.
- `CoverageData` (renamed from `CoverageRecord`) is the processed
  payload — `files: [CoverageFile]`, plus the OTel anchors.
- `CoverageRecord` is now the URL-stage input: `coverageFileURL: URL`
  plus the OTel anchors.

Pipeline (EventsExporter):
- New `CoverageProcessor` protocol — `onEnd(record:)`, `forceFlush`,
  `shutdown`. The protocol is the only coverage surface that lives in
  `EventsExporter`; concrete implementations belong on the SDK side.
- `CoverageExporterType.export(coverageData:, explicitTimeout:)`
  replaces `export(coverageRecords:)`. `EventsExporter` conforms.
- `TestCodeCoverage` builds from `[CoverageFile]` directly and does
  workspace-prefix stripping inline. The old `CoverageInfo`-based init
  and the `coveredLines` extension are gone.
- `import CodeCoverageParser` removed from every file in
  `EventsExporter`; the Xcode project drops the package product /
  target dependency on `CodeCoverageParser` from the `EventsExporter`
  target.

SDK side (DatadogSDKTesting):
- `DDCoverageHelper` → `CodeCoverageProvider`. Modelled after
  `TracerSdk` / `LoggerSdk`: one per process, owns the LLVM gatherer
  and a `CoverageProcessor`.
- `TestCoverageCollector` protocol reshaped from
  `startTest()` / `endTest(testSessionId:testSuiteId:testSpanId:)` into
  `startCoverage(context:) -> ActiveCoverage?`. The returned handle
  has a single `end()` method (idempotent) that stops LLVM gathering
  and ships the record. `ActiveCoverage` exposes its `context`.
- New `BackgroundCoverageProcessor` (was `SimpleCoverageProcessor`
  before; renamed because it owns an `OperationQueue` and is no
  longer simple). Builds the queue from a `CodeCoveragePriority`,
  parses each `CoverageRecord` URL on a background op, builds
  `CoverageData`, and forwards to a `CoverageExporterType`.
  `forceFlush` / `shutdown` drain the queue before calling through to
  the exporter. Optional `cleanupCoverageFiles` flag controls whether
  the queued op deletes the profraw after parsing.
- `LLVMCoverageProcessor.swift` carries a typealias for
  `CodeCoverage.CoverageProcessor`, isolated in a file that doesn't
  import `EventsExporter` so the OTel-protocol name stays unambiguous
  in `CodeCoverageProvider` (which imports the new protocol).
- `CodeCoverageProvider` no longer owns threading — `endTest` /
  `stop()` become straight-line handoffs to the processor.
  `init` passes `cleanupCoverageFiles: !debug` so debug-saved profraws
  survive.

`TestImpactAnalysis`:
- Holds a single `Synced<CoverageState>` slot (`collector` +
  `active: ActiveCoverage?`) instead of a separate stored
  `coverage` property and ad-hoc bookkeeping. `testWillBegin` calls
  `collector.startCoverage(context:)` and stashes the handle; on
  conflict (overlapping session) it ends the prior, logs the error,
  and nils out the collector — once cleared coverage stays off for
  the rest of the run, no separate `disabled` flag needed.
- `testDidFinish` pops `state.active` and calls `.end()`.
- `import OpenTelemetryApi` added for `SpanId`.

Tests:
- `CodeCoverageModelTests` (EventsExporter) moves to
  `Tests/DatadogSDKTesting/Coverage/CoverageBitmapTests.swift` since
  the bitmap-encoding logic now lives in the SDK module
  (`CoverageFile.init(file:)`).
- `CoverageExporterTests` / `PipelineIntegrationTests` build
  `CoverageData` directly and assert against `export(coverageData:)`.
- `Mocks.CoverageCollector` follows the new protocol: nested
  `Active: ActiveCoverage` calls back into the parent on `end()`.
- Stale `LLVMSimpleCoverageFormat.swift` (unused, orphaned) removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ypopovych
ypopovych requested review from a team as code owners May 18, 2026 17:22
ypopovych and others added 3 commits May 18, 2026 19:31
CoverageExporter writes to a fixed Caches subpath shared by every test in
the class, so the first test's payload was re-uploaded by the second
test's MockBackend reader. Wipe the directory in setUp/tearDown.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Collapses DDTestSessionSpan / DDTestModuleSpan / DDTestSuiteSpan and the
CITestEnvelope test-span path into a single TestSpan with a SpanType
discriminator, leaving DDSpan responsible only for generic spans.
Test/session/module/suite events now share one encoder, write the inner
`type` field (matches .NET wire format), and strip resource/type/version
from meta so per-event payloads carry only test-specific tags — common
metadata like language/library_version/test_session.name flows through
SpanMetadata instead. Test models expose a unified
`attributes: [String: TestAttributeValue]` with `get(tag:)` / `get(metric:)`
in place of separate `tags` / `metrics` dicts.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@datadog-datadog-prod-us1-2

This comment has been minimized.

ypopovych and others added 12 commits May 21, 2026 15:27
The TestSpan refactor moved `resource` to a top-level field on the event
(matching .NET) and stripped it from meta. Update the smoke integration
suites to assert on `span.resource` instead of `meta[DDGenericTags.resource]`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The xctestplan files declare an explicit allowlist of environment
variables to forward from xcodebuild's process env to the test bundle
subprocess via `$(VAR)` substitution. `DD_CIVISIBILITY_LOGS_ENABLED`
and `DD_CIVISIBILITY_CODE_COVERAGE_ENABLED` weren't in the list, so the
`XcodeTestRunner.Config.environment` overrides set in
`stdoutOTelAndCoverage` never reached the child SDK. The umbrella logs
flag stayed off, `StdoutCapture` was never installed, `print()` from
the test bypassed the SDK entirely, and the OTel `LoggerProvider` was
also a no-op — so backend.allLogs came back empty.

Add both keys to the unit-tests and UI-tests xctestplans so the runner
can flip them on per-test.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Code coverage used to be initialised inside TestImpactAnalysisFactory
and driven from inside the TIA feature's per-test hooks, which meant
coverage couldn't run unless TIA was also enabled — even though it has
its own enable gate (`DD_CIVISIBILITY_CODE_COVERAGE_ENABLED` + backend
`code_coverage` setting).

Pull coverage into its own `CodeCoverage` TestHooksFeature with a
matching `CodeCoverageFactory.isEnabled(config:env:remote:)`. TIA keeps
its skip / correlation-id / itr-tags responsibilities; coverage owns
the LLVM-gathering state machine, the `testCodeCoverageEnabled` tag and
the `testCoverageLines` metric on session/module spans. DDTestMonitor
now runs an independent `coverageSetup` BlockOperation off the same
`updateTracerConfig` dependency and stores the feature in `coverage`,
which is registered alongside `tia` in `activeFeatures`. The
`AdditionalTags(codeCoverage:)` fallback now keys off `coverage == nil`
rather than `tia == nil`.

Also fixes the MockBackend's settings/known-tests/test-management
responses to echo the request's JSON:API `data.id`. The SDK rejects
responses whose id doesn't match the request's, so hardcoding `"id":
"1"` broke `tracerSettings` (and cascaded into TIA / EFD / ATR / Known
Tests / Test Management all logging "backend config can't be loaded")
under the integration-test mock.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Code coverage is now a standalone feature with its own enable gate, so
the integration test no longer needs to flip on ITR/TIA. Leaving
`backend.settings.itrEnabled` at its default (`false`) exercises the
no-TIA-but-coverage path end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…ression

Refactors the JSON:API response shape into a protocol family
(APIResponseAttributesHasType / HasId / BrokenId / NoId) so each
endpoint can declare what shape its response id and type take instead
of relying on a single static equality check. This unblocks fixtures
like the known-tests / test-management responses where the backend
returns a non-matching id, and the TIA endpoint where the response
carries no id at all.

Also lifts the Content-Encoding: deflate header out of
`APIServiceConfig.defaultHeaders` and onto the per-API services
(spans, logs, telemetry, TIA coverage, git pack uploads) so each
endpoint can opt in conditionally based on `payloadCompression`. The
HTTPClient gains a `deflate(_:)` step that inflates the request body
when the header is set, and the multipart upload variant exposes
`addHTTPHeader` for the per-service injection.

Knock-on fix: `MockBackend.route(...)`'s `/api/v2/citestcov` endpoint
now feeds the inflated body into `parseCoveragePayload`, matching how
the other endpoints already handled compressed payloads.

Drops the unused OpenTelemetryApi product dependency from the
IntegrationTests-UnitTests target in the xcodeproj. Cleans up the
commented-out `withUnsafeTemporaryAllocation` branch in
`Data.deflated` (the iOS-15+/macOS-12+ implementation now ships
unconditionally).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`SpansExporter`, `LogsExporter` and `CoverageExporter` no longer carry
their own hardcoded `com.datadog.civisibility/{spans,logs,coverage}/v1`
Caches paths. Each takes a `storage: Directory` parameter and creates a
`v1` subdirectory under it. `EventsExporter.init(config:, storage:)`
fans the supplied root out by calling
`storage.createSubdirectory(path: "spans" / "logs" / "coverage")` and
passing the result to each sub-exporter.

In production, `DDTracer` pulls the root from
`DDTestMonitor.cacheManager?.session(feature: "exporter")`, so the
on-disk layout is now
`<bundleId>/<commit>/<sessionId>/exporter/{spans,logs,coverage}/v1/...`
alongside the other per-session feature dirs (`tia`, `crash`,
`known_tests`, …). If the cache manager is unavailable the exporter is
skipped and a log line is emitted.

Tests create a fresh `Directory.temporary().createSubdirectory(path:
UUID().uuidString)` per case (with `defer { try? storage.delete() }`)
so they no longer share a Caches subpath. `CoverageExporterTests` lost
its `wipeCoverageDirectory` setUp/tearDown plumbing — no longer needed.
`LibraryConfigurationServiceThrowTests` got a small
`makeExporter(baseURL:)` helper that tracks the created storages and
deletes them in `tearDown`.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`HTTPClientType` now declares just the two async methods —
`send(request:)` returning the `HTTPURLResponse` and
`sendWithResponse(request:)` returning the body — typed-throwing
`HTTPClient.RequestError`. The callback variants
(`send(request:completion:)` / `sendWithResult(request:completion:)`)
and the internal `runAsync` helper are gone.

`HTTPClient` implements the two async methods directly via a shared
`perform(_:_:)` helper that wraps `URLSession.dataTask` in a
`withCheckedContinuation` and maps the (data, response, error) tuple
through the existing `httpClientResult` / `httpClientResultWithData`
adapters.

`MockHTTPClient` mirrors the new signatures. `MockClosureDataUploader`
(the sync `DataUploaderType` shim in CoreMocks) drops its
`DispatchSemaphore` callback wait and bridges to async through
`waitForAsync`. `HTTPClientTests` convert to `async throws` —
annotated `@MainActor` so `ServerMock` still initialises on the main
thread (it precondition-checks that).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- `GitUploader`: move the "already-uploaded" sentinel check from `init`
  into `sendGitInfo(repositoryURL:commit:)`. Constructing the uploader
  no longer fails when the upload has already happened; the per-call
  guard now returns `true` (success — nothing to do) instead of
  refusing to build the feature. Trailing slash on the packfile temp
  directory path so `git pack-objects` writes into the directory
  rather than treating the trailing component as a file prefix.

- `GitUploadApi.uploadPackFiles`: drain the entire `withThrowingTaskGroup`
  with `while try await group.next() != nil {}` instead of returning
  after the first child — otherwise the rest of the uploads were
  effectively cancelled. Adds explicit `self.` capture inside the
  child-task closure.

- API response shapes: `GitUploadApi.Commit` switches from
  `APIResponseAttributesHasId` to `APIResponseAttributesNoId` (the
  packfile commit endpoint doesn't echo an id). `Array` now conforms
  to `APIAttributesAutoId` / `APIAttributesNoId` /
  `APIResponseAttributesNoId` so collection responses get the no-id
  rule for free; the manual `isIdValid` override is gone. Adds
  `APIResponseAttributesIgnoreType` as a sibling marker to
  `HasType` for endpoints that don't gate on the response `type`.

- `SettingsResponse`: reorder protocol conformances to
  `HasType, HasId` for consistency with the rest of the API services.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
`MultipartFormURLRequest.append(data:withName:contentType:)` makes the
`fileName:` parameter optional (`String? = nil`); when absent, the
`Content-Disposition` header no longer carries a `filename="…"`
attribute. Renames the parameter from `filename:` to `fileName:` to
match Swift API guidelines.

Callers that don't actually represent a file drop the argument:

- `TestImpactAnalysisApi.uploadCoverage(...)` no longer attaches
  bogus `CoverageBatch.json` / `DummyEvent.json` filenames to the
  `coverage` and `event` parts.
- `GitUploadApi.uploadPackFile(...)` drops the filename from the
  `pushedSha` JSON part (kept on the binary `packfile` part where it
  actually carries the commit-named blob).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
… types

`APICommonData` now refines `CustomDebugStringConvertible` with a
default implementation that renders the wrapper as
`{"type": "<apiType>", "id": "<id>", "attributes": <attributes>}`
(omitting `id` when nil and `attributes` when `APIVoidValue`).
`APIEnvelope` gets the matching `{"meta": <meta>, "data": <data>}` /
`{"data": <data>}` template. Both render in snake_case-ish JSON so the
`log.debug("…request: \(request)")` lines line up with the wire shape
the encoder produces.

Conforms the per-service Attribute types — Settings (request +
response, including nested EFD and TestManagement), KnownTests (TestsRequest /
TestsResponse plus the PageInfo request/response halves), TestManagement
(TestsRequest / TestsResponse), TIA (TestsRequest / TestsResponse +
Meta), and GitUpload's `Commit` / `CommitRequestMeta` — to
`CustomDebugStringConvertible` with hand-written JSON-ish renderings.
String fields are quoted, optionals print `null`, and nested
`[String: JSONGeneric]` payloads (configurations, etc.) reuse
`JSONGeneric.object(...).debugDescription` so they already format as
JSON. Multi-field renderings split across `+`-concatenated lines for
readability. `GitUploadApi.searchCommits` switches its log format
from `[meta: …, data: …]` to the new JSON-shaped `{"meta": …,
"data": …}` envelope.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The `Xcode_26.1` image on GitHub-hosted `macos-26` runners is currently
broken (Apple-side toolchain regression), so drop it from the unit-tests
and integration-tests matrices. The release workflow's `XCODE_VERSION`
moves from 26.1 to 26.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@ypopovych
ypopovych requested a review from anmarchenko May 25, 2026 11:20
@ypopovych
ypopovych merged commit de9af8a into main May 25, 2026
11 checks passed
@ypopovych
ypopovych deleted the SDTEST-3770-new-exporter-pipeline branch May 25, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants