diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dee19e..11cd8e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: jobs: macOS: - runs-on: macos-15-intel + runs-on: macos-26-intel steps: - uses: actions/checkout@v4 - uses: swift-actions/setup-swift@v2 diff --git a/Package.resolved b/Package.resolved index 2a8e780..6010636 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,33 @@ { - "originHash" : "c1d226038b44b8300825526c282440344c3ea51fc85d9e2c83795491562cbe32", + "originHash" : "240fed8717a04b633a401e3d362f4c4194cd751a7a963ec3a135672441fe7a54", "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "2fc4652fb4689eb24af10e55cabaa61d8ba774fd", + "version" : "1.32.0" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6f3615ccf2ac3c2ae0c8087d527546e9544a43dd", + "version" : "1.21.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b", + "version" : "4.15.2" + } + }, { "identity" : "grpc-swift-2", "kind" : "remoteSourceControl", @@ -37,6 +64,15 @@ "version" : "2.2.0" } }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, { "identity" : "opentelemetry-swift", "kind" : "remoteSourceControl", @@ -73,6 +109,15 @@ "version" : "4.2.1" } }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, { "identity" : "swift-algorithms", "kind" : "remoteSourceControl", @@ -127,6 +172,15 @@ "version" : "1.3.0" } }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, { "identity" : "swift-crypto", "kind" : "remoteSourceControl", @@ -141,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-distributed-tracing.git", "state" : { - "revision" : "e109d8b5308d0e05201d9a1dd1c475446a946a11", - "version" : "1.4.0" + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" } }, { @@ -279,6 +333,24 @@ "revision" : "18ff09e6b30e589ed38f90a1af23e193b8ecef8e", "version" : "1.1.2" } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "a8db2dbda8b3cdc8a61bd35128590bd296e85563", + "version" : "4.121.3" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167", + "version" : "2.16.1" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 337c758..e846361 100644 --- a/Package.swift +++ b/Package.swift @@ -36,6 +36,10 @@ let package = Package( name: "SignozSwift", targets: ["SignozSwift"] ), + .library( + name: "SignozVapor", + targets: ["SignozVapor"] + ), ], dependencies: [ .package( @@ -70,6 +74,10 @@ let package = Package( url: "https://github.com/onevcat/Rainbow.git", from: "4.0.0" ), + .package( + url: "https://github.com/vapor/vapor.git", + from: "4.89.0" + ), ], targets: [ .target( @@ -99,6 +107,13 @@ let package = Package( ), ] ), + .target( + name: "SignozVapor", + dependencies: [ + "SignozSwift", + .product(name: "Vapor", package: "vapor"), + ] + ), .testTarget( name: "SignozSwiftTests", dependencies: [ diff --git a/README.md b/README.md index e84cd40..7646352 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,19 @@ info("Request handled", attributes: ["status": 200]) ### Vapor HTTP Backend +For Vapor projects, depend on the `SignozVapor` product instead of `SignozSwift` — it re-exports everything plus the tracing middleware: + ```swift -import SignozSwift -import Vapor +.target( + name: "MyVaporApp", + dependencies: [ + .product(name: "SignozVapor", package: "SignozSwift"), + ] +), +``` + +```swift +import SignozVapor func configure(_ app: Application) throws { Signoz.start(serviceName: "my-vapor-api") { @@ -58,15 +68,15 @@ func configure(_ app: Application) throws { $0.headers = ["signoz-ingestion-key": "..."] $0.environment = "production" $0.serviceVersion = "1.0.0" - // Vapor's HTTP metrics (http_requests_total, http_request_duration_seconds) - // are automatically exported via the swift-metrics bridge. Zero extra code. } + // Automatic request tracing — creates a .server span for every HTTP request + // with OTel semantic convention attributes and W3C trace context propagation + app.middleware.use(SignozTracingMiddleware()) + app.get("users") { req async throws -> [User] in - try await span("GET /users", kind: .server) { s in - s.setAttribute(key: "http.method", value: "GET") - return try await db.fetchUsers() - } + // Spans created here are automatically nested under the request span + try await db.fetchUsers() } } @@ -74,6 +84,8 @@ func configure(_ app: Application) throws { defer { Signoz.shutdown() } ``` +`SignozTracingMiddleware` sets `http.method`, `http.target`, `http.scheme`, `http.status_code`, and `http.route` on each span. Span names use the matched route pattern (e.g. `GET /users/:id`) when available. Vapor's HTTP metrics (`http_requests_total`, `http_request_duration_seconds`) are also automatically exported via the swift-metrics bridge. + ### ArgumentParser CLI ```swift @@ -293,7 +305,7 @@ let attrs: [String: AttributeValue] = [ SignozSwift wraps the official OpenTelemetry Swift SDK — it does not reinvent any OTel types. - **[opentelemetry-swift-core](https://github.com/open-telemetry/opentelemetry-swift-core) 2.3.0** — `OpenTelemetryApi`, `OpenTelemetrySdk` -- **[opentelemetry-swift](https://github.com/open-telemetry/opentelemetry-swift) 3.0.0** — OTLP proto adapters, URLSession instrumentation, ResourceExtension, SignPost integration, SwiftMetricsShim +- **[opentelemetry-swift](https://github.com/photon-hq/opentelemetry-swift) 3.0.0** — OTLP proto adapters, URLSession instrumentation, ResourceExtension, SignPost integration, SwiftMetricsShim - **[grpc-swift-2](https://github.com/grpc/grpc-swift-2) 2.2.1** — gRPC transport (v2, async/await) - **[grpc-swift-extras](https://github.com/grpc/grpc-swift-extras) 2.1.1** — OTel tracing interceptors for automatic gRPC span injection - **[Rainbow](https://github.com/onevcat/Rainbow) 4.x** — Colored console output diff --git a/Sources/SignozSwift/Tracing/OTelTracingBridge.swift b/Sources/SignozSwift/Tracing/OTelTracingBridge.swift index 386cb34..135eaa1 100644 --- a/Sources/SignozSwift/Tracing/OTelTracingBridge.swift +++ b/Sources/SignozSwift/Tracing/OTelTracingBridge.swift @@ -9,9 +9,6 @@ import OpenTelemetrySdk struct OTelTracingBridge: Tracing.Tracer { typealias Span = BridgedSpan - private static let traceparentKey = "traceparent" - private static let tracestateKey = "tracestate" - func startSpan( _ operationName: String, context: @autoclosure () -> ServiceContext, @@ -64,17 +61,10 @@ struct OTelTracingBridge: Tracing.Tracer { return } - // W3C traceparent: version-traceId-spanId-traceFlags - let flags = spanContext.traceFlags.sampled ? "01" : "00" - let traceparent = "00-\(spanContext.traceId.hexString)-\(spanContext.spanId.hexString)-\(flags)" - injector.inject(traceparent, forKey: Self.traceparentKey, into: &carrier) - - // W3C tracestate (if non-empty) - if !spanContext.traceState.entries.isEmpty { - let tracestate = spanContext.traceState.entries - .map { "\($0.key)=\($0.value)" } - .joined(separator: ",") - injector.inject(tracestate, forKey: Self.tracestateKey, into: &carrier) + let (traceparent, tracestate) = W3CTraceContext.serialize(spanContext) + injector.inject(traceparent, forKey: W3CTraceContext.traceparentKey, into: &carrier) + if let tracestate { + injector.inject(tracestate, forKey: W3CTraceContext.tracestateKey, into: &carrier) } } @@ -83,46 +73,13 @@ struct OTelTracingBridge: Tracing.Tracer { into context: inout ServiceContext, using extractor: Extract ) where Extract.Carrier == Carrier { - guard let traceparent = extractor.extract(key: Self.traceparentKey, from: carrier) else { - return - } - - // Parse W3C traceparent: version-traceId-spanId-traceFlags - let parts = traceparent.split(separator: "-") - guard parts.count == 4, parts[0] == "00" else { + guard let traceparent = extractor.extract(key: W3CTraceContext.traceparentKey, from: carrier) else { return } - let traceId = TraceId(fromHexString: String(parts[1])) - let spanId = SpanId(fromHexString: String(parts[2])) - guard traceId.isValid, spanId.isValid else { - return + let tracestate = extractor.extract(key: W3CTraceContext.tracestateKey, from: carrier) + if let spanContext = W3CTraceContext.parse(traceparent: traceparent, tracestate: tracestate) { + context.otelSpanContext = spanContext } - - let sampled = UInt8(String(parts[3]), radix: 16).map { $0 & 0x01 != 0 } ?? false - var traceFlags = TraceFlags() - traceFlags.setIsSampled(sampled) - - // Parse tracestate if present - var traceState = TraceState() - if let tracestateHeader = extractor.extract(key: Self.tracestateKey, from: carrier) { - for entry in tracestateHeader.split(separator: ",") { - let kv = entry.split(separator: "=", maxSplits: 1) - if kv.count == 2 { - traceState = traceState.setting( - key: String(kv[0]).trimmingCharacters(in: .whitespaces), - value: String(kv[1]).trimmingCharacters(in: .whitespaces) - ) - } - } - } - - let spanContext = SpanContext.createFromRemoteParent( - traceId: traceId, - spanId: spanId, - traceFlags: traceFlags, - traceState: traceState - ) - context.otelSpanContext = spanContext } } diff --git a/Sources/SignozSwift/Tracing/W3CTraceContext.swift b/Sources/SignozSwift/Tracing/W3CTraceContext.swift new file mode 100644 index 0000000..df8ff47 --- /dev/null +++ b/Sources/SignozSwift/Tracing/W3CTraceContext.swift @@ -0,0 +1,67 @@ +@preconcurrency import OpenTelemetryApi + +/// Shared W3C Trace Context parsing and serialization. +/// +/// Used by both `OTelTracingBridge` (for gRPC distributed tracing) and +/// `SignozTracingMiddleware` (for Vapor HTTP tracing). +public enum W3CTraceContext { + + public static let traceparentKey = "traceparent" + public static let tracestateKey = "tracestate" + + /// Parse W3C `traceparent` and optional `tracestate` header values into a `SpanContext`. + public static func parse(traceparent: String, tracestate: String? = nil) -> SpanContext? { + // W3C spec: reject only version "ff"; accept unknown future versions + // for forward compatibility (the first 55 chars are guaranteed stable). + // Future versions may append extra `-` delimited fields, so require >= 4 parts. + let parts = traceparent.split(separator: "-") + guard parts.count >= 4, parts[0] != "ff" else { + return nil + } + + let traceId = TraceId(fromHexString: String(parts[1])) + let spanId = SpanId(fromHexString: String(parts[2])) + guard traceId.isValid, spanId.isValid else { + return nil + } + + let sampled = UInt8(String(parts[3]), radix: 16).map { $0 & 0x01 != 0 } ?? false + var traceFlags = TraceFlags() + traceFlags.setIsSampled(sampled) + + var state = TraceState() + if let tracestateHeader = tracestate { + for entry in tracestateHeader.split(separator: ",") { + let kv = entry.split(separator: "=", maxSplits: 1) + if kv.count == 2 { + state = state.setting( + key: String(kv[0]).trimmingCharacters(in: .whitespaces), + value: String(kv[1]).trimmingCharacters(in: .whitespaces) + ) + } + } + } + + return SpanContext.createFromRemoteParent( + traceId: traceId, + spanId: spanId, + traceFlags: traceFlags, + traceState: state + ) + } + + /// Serialize a `SpanContext` into W3C `traceparent` and optional `tracestate` header values. + public static func serialize(_ spanContext: SpanContext) -> (traceparent: String, tracestate: String?) { + let flags = spanContext.traceFlags.sampled ? "01" : "00" + let traceparent = "00-\(spanContext.traceId.hexString)-\(spanContext.spanId.hexString)-\(flags)" + + var tracestate: String? = nil + if !spanContext.traceState.entries.isEmpty { + tracestate = spanContext.traceState.entries + .map { "\($0.key)=\($0.value)" } + .joined(separator: ",") + } + + return (traceparent, tracestate) + } +} diff --git a/Sources/SignozVapor/SignozTracingMiddleware.swift b/Sources/SignozVapor/SignozTracingMiddleware.swift new file mode 100644 index 0000000..edecd98 --- /dev/null +++ b/Sources/SignozVapor/SignozTracingMiddleware.swift @@ -0,0 +1,109 @@ +@_exported import SignozSwift +@preconcurrency import OpenTelemetryApi +import Vapor + +/// Vapor middleware that automatically creates OpenTelemetry `.server` spans +/// for every incoming HTTP request with standard OTel HTTP semantic convention attributes. +/// +/// Register the middleware to opt in to automatic request tracing: +/// ```swift +/// import SignozVapor +/// +/// app.middleware.use(SignozTracingMiddleware()) +/// ``` +public struct SignozTracingMiddleware: AsyncMiddleware { + + public init() {} + + public func respond( + to request: Request, + chainingTo next: any AsyncResponder + ) async throws -> Response { + let method = request.method.rawValue + let path = request.url.path + + let builder = Signoz.tracer.spanBuilder(spanName: "\(method) \(path)") + builder.setSpanKind(spanKind: .server) + + // Extract W3C traceparent/tracestate from request headers + if let traceparent = request.headers.first(name: W3CTraceContext.traceparentKey), + let parentContext = W3CTraceContext.parse( + traceparent: traceparent, + tracestate: request.headers.first(name: W3CTraceContext.tracestateKey) + ) { + builder.setParent(parentContext) + } + + let span = builder.startSpan() + span.setAttribute(key: "http.method", value: .string(method)) + span.setAttribute(key: "http.target", value: .string(path)) + span.setAttribute(key: "http.scheme", value: .string(request.url.scheme ?? "http")) + + do { + let response = try await OpenTelemetry.instance.contextProvider + .withActiveSpan(span) { + try await next.respond(to: request) + } + + // Route is available after the responder chain resolves the route + if let route = request.route { + let pattern = Self.routePattern(route) + span.name = "\(method) \(pattern)" + span.setAttribute(key: "http.route", value: .string(pattern)) + } + + let statusCode = Int(response.status.code) + span.setAttribute(key: "http.status_code", value: .int(statusCode)) + + if response.status.code >= 500 { + span.status = .error(description: "\(response.status)") + } else { + span.status = .ok + } + + Self.injectTraceContext(spanContext: span.context, into: response) + span.end() + return response + } catch { + if let route = request.route { + let pattern = Self.routePattern(route) + span.name = "\(method) \(pattern)" + span.setAttribute(key: "http.route", value: .string(pattern)) + } + if let abort = error as? AbortError { + let status = abort.status + span.setAttribute(key: "http.status_code", value: .int(Int(status.code))) + span.status = .error(description: "\(status)") + } else { + span.setAttribute(key: "http.status_code", value: .int(500)) + span.status = .error(description: String(describing: type(of: error))) + } + span.end() + throw error + } + } + + // MARK: - Route Pattern + + private static func routePattern(_ route: Route) -> String { + "/" + route.path.map { component in + switch component { + case .constant(let value): return value + case .parameter(let name): return ":\(name)" + case .anything: return "*" + case .catchall: return "**" + } + }.joined(separator: "/") + } + + // MARK: - W3C Trace Context + + private static func injectTraceContext(spanContext: SpanContext, into response: Response) { + guard spanContext.isValid else { return } + let (traceparent, tracestate) = W3CTraceContext.serialize(spanContext) + response.headers.replaceOrAdd(name: W3CTraceContext.traceparentKey, value: traceparent) + if let tracestate { + response.headers.replaceOrAdd(name: W3CTraceContext.tracestateKey, value: tracestate) + } + } +}