From 4f749dd6764ec0e48f25d54a70450fbf87fb28ac Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 10 Mar 2026 00:45:00 -0700 Subject: [PATCH 1/9] Add SignozVapor target and tracing middleware Introduce SignozVapor library and SignozTracingMiddleware which creates OpenTelemetry server spans for incoming Vapor requests and extracts/injects W3C traceparent/tracestate. Add Vapor package dependency and update Package.resolved pins --- Package.resolved | 78 +++++++++- Package.swift | 15 ++ .../SignozVapor/SignozTracingMiddleware.swift | 146 ++++++++++++++++++ 3 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 Sources/SignozVapor/SignozTracingMiddleware.swift 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/Sources/SignozVapor/SignozTracingMiddleware.swift b/Sources/SignozVapor/SignozTracingMiddleware.swift new file mode 100644 index 0000000..d1e33c3 --- /dev/null +++ b/Sources/SignozVapor/SignozTracingMiddleware.swift @@ -0,0 +1,146 @@ +@_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 parentContext = Self.extractTraceContext(from: request) { + 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(span: span, 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)) + } + span.status = .error(description: "\(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 extractTraceContext(from request: Request) -> SpanContext? { + guard let traceparent = request.headers.first(name: "traceparent") else { + return nil + } + + let parts = traceparent.split(separator: "-") + guard parts.count == 4, parts[0] == "00" 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 traceState = TraceState() + if let tracestateHeader = request.headers.first(name: "tracestate") { + 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) + ) + } + } + } + + return SpanContext.createFromRemoteParent( + traceId: traceId, + spanId: spanId, + traceFlags: traceFlags, + traceState: traceState + ) + } + + private static func injectTraceContext(span: any OpenTelemetryApi.Span, into response: Response) { + let ctx = span.context + guard ctx.isValid else { return } + + let flags = ctx.traceFlags.sampled ? "01" : "00" + let traceparent = "00-\(ctx.traceId.hexString)-\(ctx.spanId.hexString)-\(flags)" + response.headers.replaceOrAdd(name: "traceparent", value: traceparent) + + if !ctx.traceState.entries.isEmpty { + let tracestate = ctx.traceState.entries + .map { "\($0.key)=\($0.value)" } + .joined(separator: ",") + response.headers.replaceOrAdd(name: "tracestate", value: tracestate) + } + } +} From be09ecb8b0df38844f470348f5dee828869c2ae2 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 10 Mar 2026 00:46:18 -0700 Subject: [PATCH 2/9] Update README to document SignozVapor middleware Co-Authored-By: Claude Opus 4.6 --- README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e84cd40..36b243e 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,12 @@ dependencies: [ ] ``` -Then add `"SignozSwift"` to your target's dependencies: +Then add `"SignozSwift"` to your target's dependencies (or `"SignozVapor"` for Vapor projects): ```swift .target( name: "MyApp", - dependencies: ["SignozSwift"] + dependencies: ["SignozSwift"] // or "SignozVapor" for Vapor apps ), ``` @@ -47,9 +47,10 @@ info("Request handled", attributes: ["status": 200]) ### Vapor HTTP Backend +Add `"SignozVapor"` to your target's dependencies instead of `"SignozSwift"` — it re-exports everything from `SignozSwift` plus the tracing middleware: + ```swift -import SignozSwift -import Vapor +import SignozVapor func configure(_ app: Application) throws { Signoz.start(serviceName: "my-vapor-api") { @@ -58,15 +59,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 +75,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 From 6957f1eb30295bfddbe4b7b79e1518328f25ef1f Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 10 Mar 2026 01:20:05 -0700 Subject: [PATCH 3/9] Use package traits for conditional Vapor dependency Replace the separate SignozVapor target with a package trait. The middleware now lives in SignozSwift behind #if SIGNOZ_VAPOR, activated via .package(..., traits: ["Vapor"]). This avoids pulling Vapor into consumers that don't need it. Co-Authored-By: Claude Opus 4.6 --- Package.resolved | 74 +------------------ Package.swift | 19 ++--- README.md | 17 ++++- .../Vapor}/SignozTracingMiddleware.swift | 14 +++- 4 files changed, 31 insertions(+), 93 deletions(-) rename Sources/{SignozVapor => SignozSwift/Vapor}/SignozTracingMiddleware.swift (94%) diff --git a/Package.resolved b/Package.resolved index 6010636..7c9fd67 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,33 +1,6 @@ { - "originHash" : "240fed8717a04b633a401e3d362f4c4194cd751a7a963ec3a135672441fe7a54", + "originHash" : "78f1a874d439b11a0c99d3bd37ad9edcb266f9c773fd1f6642916e4fb5ad8487", "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", @@ -64,15 +37,6 @@ "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", @@ -109,15 +73,6 @@ "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", @@ -172,15 +127,6 @@ "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", @@ -333,24 +279,6 @@ "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 e846361..6aefa73 100644 --- a/Package.swift +++ b/Package.swift @@ -36,10 +36,9 @@ let package = Package( name: "SignozSwift", targets: ["SignozSwift"] ), - .library( - name: "SignozVapor", - targets: ["SignozVapor"] - ), + ], + traits: [ + .trait(name: "Vapor", description: "Enable Vapor HTTP middleware integration"), ], dependencies: [ .package( @@ -82,7 +81,9 @@ let package = Package( targets: [ .target( name: "SignozSwift", - dependencies: signozDependencies, + dependencies: signozDependencies + [ + .product(name: "Vapor", package: "vapor", condition: .when(traits: ["Vapor"])), + ], swiftSettings: [ .enableExperimentalFeature( "AvailabilityMacro=gRPCSwiftExtras 2.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0" @@ -105,13 +106,7 @@ let package = Package( .enableExperimentalFeature( "AvailabilityMacro=gRPCSwiftNIOTransport 2.4:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0" ), - ] - ), - .target( - name: "SignozVapor", - dependencies: [ - "SignozSwift", - .product(name: "Vapor", package: "vapor"), + .define("SIGNOZ_VAPOR", .when(traits: ["Vapor"])), ] ), .testTarget( diff --git a/README.md b/README.md index 36b243e..558d5ef 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,20 @@ dependencies: [ ] ``` -Then add `"SignozSwift"` to your target's dependencies (or `"SignozVapor"` for Vapor projects): +For Vapor projects, enable the `Vapor` trait to get automatic HTTP request tracing: + +```swift +dependencies: [ + .package(url: "https://github.com/photon-hq/SignozSwift.git", from: "0.1.0", traits: ["Vapor"]), +] +``` + +Then add `"SignozSwift"` to your target's dependencies: ```swift .target( name: "MyApp", - dependencies: ["SignozSwift"] // or "SignozVapor" for Vapor apps + dependencies: ["SignozSwift"] ), ``` @@ -47,10 +55,11 @@ info("Request handled", attributes: ["status": 200]) ### Vapor HTTP Backend -Add `"SignozVapor"` to your target's dependencies instead of `"SignozSwift"` — it re-exports everything from `SignozSwift` plus the tracing middleware: +Enable the `Vapor` trait in your package dependency (see [Installation](#installation)), then register the middleware: ```swift -import SignozVapor +import SignozSwift +import Vapor func configure(_ app: Application) throws { Signoz.start(serviceName: "my-vapor-api") { diff --git a/Sources/SignozVapor/SignozTracingMiddleware.swift b/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift similarity index 94% rename from Sources/SignozVapor/SignozTracingMiddleware.swift rename to Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift index d1e33c3..f0897cf 100644 --- a/Sources/SignozVapor/SignozTracingMiddleware.swift +++ b/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift @@ -1,13 +1,18 @@ -@_exported import SignozSwift -@preconcurrency import OpenTelemetryApi +#if SIGNOZ_VAPOR import Vapor +@preconcurrency import OpenTelemetryApi /// 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: +/// Enable by adding the `Vapor` trait to your SignozSwift dependency: +/// ```swift +/// .package(url: "https://github.com/photon-hq/SignozSwift.git", from: "0.1.0", traits: ["Vapor"]) +/// ``` +/// +/// Then register the middleware: /// ```swift -/// import SignozVapor +/// import SignozSwift /// /// app.middleware.use(SignozTracingMiddleware()) /// ``` @@ -144,3 +149,4 @@ public struct SignozTracingMiddleware: AsyncMiddleware { } } } +#endif From 2c6eefaea292ce79ca26ffd26ffa29999b0a20bf Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 10 Mar 2026 01:23:13 -0700 Subject: [PATCH 4/9] Extract shared W3C trace context utility Move traceparent/tracestate parsing and serialization into W3CTraceContext so OTelTracingBridge and SignozTracingMiddleware share a single implementation. Co-Authored-By: Claude Opus 4.6 --- .../Tracing/OTelTracingBridge.swift | 59 +++------------- .../SignozSwift/Tracing/W3CTraceContext.swift | 64 +++++++++++++++++ .../Vapor/SignozTracingMiddleware.swift | 68 ++++--------------- 3 files changed, 84 insertions(+), 107 deletions(-) create mode 100644 Sources/SignozSwift/Tracing/W3CTraceContext.swift 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..aa6612e --- /dev/null +++ b/Sources/SignozSwift/Tracing/W3CTraceContext.swift @@ -0,0 +1,64 @@ +@preconcurrency import OpenTelemetryApi + +/// Shared W3C Trace Context parsing and serialization. +/// +/// Used by both `OTelTracingBridge` (for gRPC distributed tracing) and +/// `SignozTracingMiddleware` (for Vapor HTTP tracing). +enum W3CTraceContext { + + static let traceparentKey = "traceparent" + static let tracestateKey = "tracestate" + + /// Parse W3C `traceparent` and optional `tracestate` header values into a `SpanContext`. + static func parse(traceparent: String, tracestate: String? = nil) -> SpanContext? { + let parts = traceparent.split(separator: "-") + guard parts.count == 4, parts[0] == "00" 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. + 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/SignozSwift/Vapor/SignozTracingMiddleware.swift b/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift index f0897cf..2babd87 100644 --- a/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift +++ b/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift @@ -31,7 +31,11 @@ public struct SignozTracingMiddleware: AsyncMiddleware { builder.setSpanKind(spanKind: .server) // Extract W3C traceparent/tracestate from request headers - if let parentContext = Self.extractTraceContext(from: request) { + 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) } @@ -62,7 +66,7 @@ public struct SignozTracingMiddleware: AsyncMiddleware { span.status = .ok } - Self.injectTraceContext(span: span, into: response) + Self.injectTraceContext(spanContext: span.context, into: response) span.end() return response } catch { @@ -92,60 +96,12 @@ public struct SignozTracingMiddleware: AsyncMiddleware { // MARK: - W3C Trace Context - private static func extractTraceContext(from request: Request) -> SpanContext? { - guard let traceparent = request.headers.first(name: "traceparent") else { - return nil - } - - let parts = traceparent.split(separator: "-") - guard parts.count == 4, parts[0] == "00" 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 traceState = TraceState() - if let tracestateHeader = request.headers.first(name: "tracestate") { - 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) - ) - } - } - } - - return SpanContext.createFromRemoteParent( - traceId: traceId, - spanId: spanId, - traceFlags: traceFlags, - traceState: traceState - ) - } - - private static func injectTraceContext(span: any OpenTelemetryApi.Span, into response: Response) { - let ctx = span.context - guard ctx.isValid else { return } - - let flags = ctx.traceFlags.sampled ? "01" : "00" - let traceparent = "00-\(ctx.traceId.hexString)-\(ctx.spanId.hexString)-\(flags)" - response.headers.replaceOrAdd(name: "traceparent", value: traceparent) - - if !ctx.traceState.entries.isEmpty { - let tracestate = ctx.traceState.entries - .map { "\($0.key)=\($0.value)" } - .joined(separator: ",") - response.headers.replaceOrAdd(name: "tracestate", value: tracestate) + 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) } } } From cc73520c4b62e5594b00b115bc81555687a4d2d9 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 10 Mar 2026 01:23:58 -0700 Subject: [PATCH 5/9] Fix W3C traceparent version check for forward compatibility Per the W3C Trace Context spec, only version "ff" is invalid. Unknown future versions should be accepted since the first 55 characters are guaranteed stable. Changed the guard from == "00" to != "ff" and accept >= 4 dash-delimited parts to allow extra fields from future versions. Co-Authored-By: Claude Opus 4.6 --- Sources/SignozSwift/Tracing/W3CTraceContext.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/SignozSwift/Tracing/W3CTraceContext.swift b/Sources/SignozSwift/Tracing/W3CTraceContext.swift index aa6612e..8137706 100644 --- a/Sources/SignozSwift/Tracing/W3CTraceContext.swift +++ b/Sources/SignozSwift/Tracing/W3CTraceContext.swift @@ -11,8 +11,11 @@ enum W3CTraceContext { /// Parse W3C `traceparent` and optional `tracestate` header values into a `SpanContext`. 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] == "00" else { + guard parts.count >= 4, parts[0] != "ff" else { return nil } From a6e1c270d4dac79f2c6643f9f67d431af436968b Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 10 Mar 2026 01:25:18 -0700 Subject: [PATCH 6/9] Set http.status_code and sanitize error in catch block Extract HTTP status from Vapor's AbortError when available, otherwise default to 500. Use type name instead of raw error string to avoid leaking internal details into span status. Co-Authored-By: Claude Opus 4.6 --- Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift b/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift index 2babd87..6b82629 100644 --- a/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift +++ b/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift @@ -75,7 +75,14 @@ public struct SignozTracingMiddleware: AsyncMiddleware { span.name = "\(method) \(pattern)" span.setAttribute(key: "http.route", value: .string(pattern)) } - span.status = .error(description: "\(error)") + 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 } From ca8cdfc3cfec5745f9831dc2a39746564db7ae58 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 10 Mar 2026 01:36:56 -0700 Subject: [PATCH 7/9] Use separate SignozVapor target for Vapor middleware Move middleware from inline #if SIGNOZ_VAPOR to a dedicated SignozVapor target. Vapor is fetched as a package dependency but only compiled when a consumer depends on SignozVapor. Remove traits machinery. Make W3CTraceContext public so SignozVapor can reuse it. Co-Authored-By: Claude Opus 4.6 --- Package.resolved | 74 ++++++++++++++++++- Package.swift | 19 +++-- README.md | 22 +++--- .../SignozSwift/Tracing/W3CTraceContext.swift | 10 +-- .../SignozTracingMiddleware.swift | 14 +--- 5 files changed, 105 insertions(+), 34 deletions(-) rename Sources/{SignozSwift/Vapor => SignozVapor}/SignozTracingMiddleware.swift (93%) diff --git a/Package.resolved b/Package.resolved index 7c9fd67..6010636 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,33 @@ { - "originHash" : "78f1a874d439b11a0c99d3bd37ad9edcb266f9c773fd1f6642916e4fb5ad8487", + "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", @@ -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 6aefa73..e846361 100644 --- a/Package.swift +++ b/Package.swift @@ -36,9 +36,10 @@ let package = Package( name: "SignozSwift", targets: ["SignozSwift"] ), - ], - traits: [ - .trait(name: "Vapor", description: "Enable Vapor HTTP middleware integration"), + .library( + name: "SignozVapor", + targets: ["SignozVapor"] + ), ], dependencies: [ .package( @@ -81,9 +82,7 @@ let package = Package( targets: [ .target( name: "SignozSwift", - dependencies: signozDependencies + [ - .product(name: "Vapor", package: "vapor", condition: .when(traits: ["Vapor"])), - ], + dependencies: signozDependencies, swiftSettings: [ .enableExperimentalFeature( "AvailabilityMacro=gRPCSwiftExtras 2.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0" @@ -106,7 +105,13 @@ let package = Package( .enableExperimentalFeature( "AvailabilityMacro=gRPCSwiftNIOTransport 2.4:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0" ), - .define("SIGNOZ_VAPOR", .when(traits: ["Vapor"])), + ] + ), + .target( + name: "SignozVapor", + dependencies: [ + "SignozSwift", + .product(name: "Vapor", package: "vapor"), ] ), .testTarget( diff --git a/README.md b/README.md index 558d5ef..76487ef 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,6 @@ dependencies: [ ] ``` -For Vapor projects, enable the `Vapor` trait to get automatic HTTP request tracing: - -```swift -dependencies: [ - .package(url: "https://github.com/photon-hq/SignozSwift.git", from: "0.1.0", traits: ["Vapor"]), -] -``` - Then add `"SignozSwift"` to your target's dependencies: ```swift @@ -55,11 +47,19 @@ info("Request handled", attributes: ["status": 200]) ### Vapor HTTP Backend -Enable the `Vapor` trait in your package dependency (see [Installation](#installation)), then register the middleware: +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") { diff --git a/Sources/SignozSwift/Tracing/W3CTraceContext.swift b/Sources/SignozSwift/Tracing/W3CTraceContext.swift index 8137706..df8ff47 100644 --- a/Sources/SignozSwift/Tracing/W3CTraceContext.swift +++ b/Sources/SignozSwift/Tracing/W3CTraceContext.swift @@ -4,13 +4,13 @@ /// /// Used by both `OTelTracingBridge` (for gRPC distributed tracing) and /// `SignozTracingMiddleware` (for Vapor HTTP tracing). -enum W3CTraceContext { +public enum W3CTraceContext { - static let traceparentKey = "traceparent" - static let tracestateKey = "tracestate" + public static let traceparentKey = "traceparent" + public static let tracestateKey = "tracestate" /// Parse W3C `traceparent` and optional `tracestate` header values into a `SpanContext`. - static func parse(traceparent: String, tracestate: String? = nil) -> 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. @@ -51,7 +51,7 @@ enum W3CTraceContext { } /// Serialize a `SpanContext` into W3C `traceparent` and optional `tracestate` header values. - static func serialize(_ spanContext: SpanContext) -> (traceparent: String, tracestate: String?) { + 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)" diff --git a/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift b/Sources/SignozVapor/SignozTracingMiddleware.swift similarity index 93% rename from Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift rename to Sources/SignozVapor/SignozTracingMiddleware.swift index 6b82629..edecd98 100644 --- a/Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift +++ b/Sources/SignozVapor/SignozTracingMiddleware.swift @@ -1,18 +1,13 @@ -#if SIGNOZ_VAPOR -import Vapor +@_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. /// -/// Enable by adding the `Vapor` trait to your SignozSwift dependency: -/// ```swift -/// .package(url: "https://github.com/photon-hq/SignozSwift.git", from: "0.1.0", traits: ["Vapor"]) -/// ``` -/// -/// Then register the middleware: +/// Register the middleware to opt in to automatic request tracing: /// ```swift -/// import SignozSwift +/// import SignozVapor /// /// app.middleware.use(SignozTracingMiddleware()) /// ``` @@ -112,4 +107,3 @@ public struct SignozTracingMiddleware: AsyncMiddleware { } } } -#endif From e940fbd824230a6ff8f4d6fa7c92f22ad622c3c6 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 10 Mar 2026 01:40:18 -0700 Subject: [PATCH 8/9] Docs: change opentelemetry-swift README link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 76487ef..7646352 100644 --- a/README.md +++ b/README.md @@ -305,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 From 19d01370c0f463a2ad8f37c9a33041b5c22fa6f0 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Tue, 10 Mar 2026 11:42:37 -0700 Subject: [PATCH 9/9] CI: Bump macOS runner to macos-26-intel --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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