diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
new file mode 100644
index 00000000..d0749a77
--- /dev/null
+++ b/.github/workflows/integration.yml
@@ -0,0 +1,43 @@
+name: "Integration Tests"
+on: [push, pull_request]
+jobs:
+ integration_test:
+ name: Integration Tests
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ swift: ["5.9", "5.10", "latest"]
+
+ steps:
+ - name: Install Swift
+ uses: vapor/swiftly-action@v0.1
+ with:
+ toolchain: ${{ matrix.swift }}
+ env:
+ SWIFTLY_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Checkout
+ uses: actions/checkout@v4.2.0
+
+ - name: Resolve Swift dependencies
+ run: |
+ cd IntegrationTests
+ swift package resolve
+
+ - name: Start OTel Collector
+ run: |
+ cd IntegrationTests
+ docker compose -f docker/docker-compose.yaml up -d
+
+ - name: Wait for OTel Collector
+ uses: iFaxity/wait-on-action@v1.2.1
+ with:
+ resource: "file:./IntegrationTests/docker/otel-collector-output/output.jsonl"
+ timeout: 3000
+
+ - name: Run Integration Tests
+ run: |
+ cd IntegrationTests
+ OTEL_COLLECTOR_OUTPUT=$(pwd)/docker/otel-collector-output swift test
diff --git a/.gitignore b/.gitignore
index 70f7b5ef..8f9089ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@ xcuserdata/
.protoc-grpc-swift-plugins.download/
swift-otel-workspace.xcworkspace/
.benchmarkBaselines
+IntegrationTests/docker/otel-collector-output/
diff --git a/IntegrationTests/Package.swift b/IntegrationTests/Package.swift
new file mode 100644
index 00000000..91cb8c7e
--- /dev/null
+++ b/IntegrationTests/Package.swift
@@ -0,0 +1,22 @@
+// swift-tools-version:5.9
+import PackageDescription
+
+let package = Package(
+ name: "swift-otel-integration-tests",
+ dependencies: [
+ .package(path: "../"),
+ .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "2.0.0"),
+ ],
+ targets: [
+ .testTarget(
+ name: "IntegrationTests",
+ dependencies: [
+ .product(name: "OTel", package: "swift-otel"),
+ .product(name: "OTLPGRPC", package: "swift-otel"),
+ .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"),
+ ],
+ swiftSettings: [.enableExperimentalFeature("StrictConcurrency=complete")]
+ ),
+ ],
+ swiftLanguageVersions: [.version("6"), .v5]
+)
diff --git a/IntegrationTests/Tests/IntegrationTests/OTLPGRPCIntegrationTests.swift b/IntegrationTests/Tests/IntegrationTests/OTLPGRPCIntegrationTests.swift
new file mode 100644
index 00000000..e24c6ce4
--- /dev/null
+++ b/IntegrationTests/Tests/IntegrationTests/OTLPGRPCIntegrationTests.swift
@@ -0,0 +1,133 @@
+//===----------------------------------------------------------------------===//
+//
+// This source file is part of the Swift OTel open source project
+//
+// Copyright (c) 2024 Moritz Lang and the Swift OTel project authors
+// Licensed under Apache License v2.0
+//
+// See LICENSE.txt for license information
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+//===----------------------------------------------------------------------===//
+
+import Foundation
+@testable import Instrumentation
+@testable import Logging
+import NIO
+import OTel
+import OTLPGRPC
+import ServiceLifecycle
+import W3CTraceContext
+import XCTest
+
+final class OTLPGRPCIntegrationTests: XCTestCase, @unchecked Sendable {
+ func test_example() async throws {
+ LoggingSystem.bootstrapInternal { label in
+ var handler = StreamLogHandler.standardOutput(label: label)
+ handler.logLevel = .trace
+ return handler
+ }
+ let logger = Logger(label: "test")
+ let group = MultiThreadedEventLoopGroup.singleton
+ let exporter = try OTLPGRPCSpanExporter(
+ configuration: OTLPGRPCSpanExporterConfiguration(environment: [:]),
+ group: group,
+ requestLogger: logger,
+ backgroundActivityLogger: logger
+ )
+ let processor = OTelBatchSpanProcessor(
+ exporter: exporter,
+ configuration: .init(
+ environment: [:],
+ scheduleDelay: .zero
+ )
+ )
+ let tracer = OTelTracer(
+ idGenerator: OTelRandomIDGenerator(),
+ sampler: OTelConstantSampler(isOn: true),
+ propagator: OTelW3CPropagator(),
+ processor: processor,
+ environment: [:],
+ resource: OTelResource(attributes: ["service.name": "IntegrationTests"])
+ )
+
+ InstrumentationSystem.bootstrapInternal(tracer)
+
+ let serviceGroup = ServiceGroup(
+ configuration: .init(
+ services: [
+ .init(service: tracer),
+ .init(service: TestService(), successTerminationBehavior: .gracefullyShutdownGroup),
+ ],
+ logger: logger
+ )
+ )
+ try await serviceGroup.run()
+ }
+}
+
+struct TestService: Service {
+ func run() async throws {
+ let otelCollectorOutputPath = try XCTUnwrap(ProcessInfo.processInfo.environment["OTEL_COLLECTOR_OUTPUT"])
+ let outputFileURL = URL(fileURLWithPath: otelCollectorOutputPath).appendingPathComponent("output.jsonl")
+ XCTAssertTrue(FileManager.default.fileExists(atPath: outputFileURL.path), outputFileURL.path)
+
+ let span = InstrumentationSystem.tracer.startSpan("test")
+ span.attributes["foo"] = "bar"
+ span.setStatus(.init(code: .ok))
+ span.end()
+
+ // wait for export
+ try await Task.sleep(for: .seconds(2))
+
+ let jsonDecoder = JSONDecoder()
+ let outputFileContents = try String(contentsOf: outputFileURL).trimmingCharacters(in: .whitespacesAndNewlines)
+ let lines = outputFileContents.components(separatedBy: .newlines)
+ let exportLine = try XCTUnwrap(lines.last)
+ let decodedExportLine = try jsonDecoder.decode(ExportLine.self, from: Data(exportLine.utf8))
+ let resourceSpans = try XCTUnwrap(decodedExportLine.resourceSpans.first)
+ let scopeSpans = try XCTUnwrap(resourceSpans.scopeSpans.first)
+ let exportedSpan = try XCTUnwrap(scopeSpans.spans.first)
+
+ XCTAssertEqual(exportedSpan.spanID, span.context.spanContext?.spanID.description)
+ XCTAssertEqual(exportedSpan.traceID, span.context.spanContext?.traceID.description)
+ XCTAssertEqual(exportedSpan.name, "test")
+ XCTAssertEqual(exportedSpan.attributes, [.init(key: "foo", value: .init(stringValue: "bar"))])
+ }
+}
+
+struct ExportLine: Decodable {
+ let resourceSpans: [ResourceSpan]
+
+ struct ResourceSpan: Decodable {
+ let scopeSpans: [ScopeSpans]
+
+ struct ScopeSpans: Decodable {
+ let spans: [Span]
+
+ struct Span: Decodable {
+ let traceID: String
+ let spanID: String
+ let name: String
+ let attributes: [Attribute]
+
+ private enum CodingKeys: String, CodingKey {
+ case traceID = "traceId"
+ case spanID = "spanId"
+ case name
+ case attributes
+ }
+
+ struct Attribute: Decodable, Equatable {
+ let key: String
+ let value: Value
+
+ struct Value: Decodable, Equatable {
+ let stringValue: String
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/IntegrationTests/docker/docker-compose.yaml b/IntegrationTests/docker/docker-compose.yaml
new file mode 100644
index 00000000..85d6c1d6
--- /dev/null
+++ b/IntegrationTests/docker/docker-compose.yaml
@@ -0,0 +1,12 @@
+name: swift-otel-integration-tests
+services:
+ otel-collector:
+ image: otel/opentelemetry-collector-contrib:latest
+ command: ["--config=/etc/otel-collector-config.yaml"]
+ volumes:
+ - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
+ - ./otel-collector-output:/etc/otel-collector-output
+ ports:
+ - "4317:4317" # OTLP gRPC receiver
+
+# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json
diff --git a/IntegrationTests/docker/otel-collector-config.yaml b/IntegrationTests/docker/otel-collector-config.yaml
new file mode 100644
index 00000000..02a3d570
--- /dev/null
+++ b/IntegrationTests/docker/otel-collector-config.yaml
@@ -0,0 +1,17 @@
+receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: "otel-collector:4317"
+
+exporters:
+ file:
+ path: /etc/otel-collector-output/output.jsonl
+
+service:
+ pipelines:
+ traces:
+ receivers: [otlp]
+ exporters: [file]
+
+# yaml-language-server: $schema=https://raw.githubusercontent.com/srikanthccv/otelcol-jsonschema/main/schema.json
diff --git a/Makefile b/Makefile
index 885ef2b2..d4f7e741 100644
--- a/Makefile
+++ b/Makefile
@@ -141,6 +141,9 @@ define contents_xcworkspacedata
+
+
+
endef
export contents_xcworkspacedata
diff --git a/Sources/OTel/Tracing/Processing/Batch/OTelBatchSpanProcessor.swift b/Sources/OTel/Tracing/Processing/Batch/OTelBatchSpanProcessor.swift
index 94b89b7c..21bfd59b 100644
--- a/Sources/OTel/Tracing/Processing/Batch/OTelBatchSpanProcessor.swift
+++ b/Sources/OTel/Tracing/Processing/Batch/OTelBatchSpanProcessor.swift
@@ -119,7 +119,6 @@ public actor OTelBatchSpanProcessor & Sendable) async {
let batchID = batchID
self.batchID += 1
- print(batchID)
var exportLogger = logger
exportLogger[metadataKey: "batch_id"] = "\(batchID)"
diff --git a/scripts/validate_format.sh b/scripts/validate_format.sh
index 106ac0d2..da56eb3b 100755
--- a/scripts/validate_format.sh
+++ b/scripts/validate_format.sh
@@ -36,7 +36,7 @@ printf "=> Checking format\n"
FIRST_OUT="$(git status --porcelain)"
# swiftformat does not scale so we loop ourselves
shopt -u dotglob
-find Sources/* Tests/* Examples/* Benchmarks/* -type d -not -path "*/Generated*" | while IFS= read -r d; do
+find Sources/* Tests/* Examples/* Benchmarks/* IntegrationTests/* -type d -not -path "*/Generated*" | while IFS= read -r d; do
printf " * checking $d... "
out=$(mint run swiftformat -quiet $d 2>&1)
if [[ $out == *$'\n' ]]; then
diff --git a/scripts/validate_license_headers.sh b/scripts/validate_license_headers.sh
index 6ea97cfc..e1a06299 100755
--- a/scripts/validate_license_headers.sh
+++ b/scripts/validate_license_headers.sh
@@ -112,6 +112,7 @@ EOF
find . \
\( \! -path './.build/*' \) -a \
\( \! -path './Benchmarks/.build/*' \) -a \
+ \( \! -path './IntegrationTests/.build/*' \) -a \
\( \! -path '*/Generated/*' \) -a \
\( "${matching_files[@]}" \) -a \
\( \! \( "${exceptions[@]}" \) \) | while read line; do