Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ let package = Package(
targets: ["SignozSwift"]
),
],
traits: [
.trait(name: "Vapor", description: "Enable Vapor HTTP middleware integration"),
],
dependencies: [
.package(
url: "https://github.com/open-telemetry/opentelemetry-swift-core",
Expand Down Expand Up @@ -70,11 +73,17 @@ 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"
),
Comment thread
underthestars-zhy marked this conversation as resolved.
],
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"
Expand All @@ -97,6 +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"
),
.define("SIGNOZ_VAPOR", .when(traits: ["Vapor"])),
]
),
.testTarget(
Expand Down
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ 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
Expand Down Expand Up @@ -47,6 +55,8 @@ info("Request handled", attributes: ["status": 200])

### Vapor HTTP Backend

Enable the `Vapor` trait in your package dependency (see [Installation](#installation)), then register the middleware:

```swift
import SignozSwift
import Vapor
Expand All @@ -58,22 +68,24 @@ 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()
}
}

// In entrypoint:
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
Expand Down
59 changes: 8 additions & 51 deletions Sources/SignozSwift/Tracing/OTelTracingBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Instant: TracerInstant>(
_ operationName: String,
context: @autoclosure () -> ServiceContext,
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
}
}
67 changes: 67 additions & 0 deletions Sources/SignozSwift/Tracing/W3CTraceContext.swift
Original file line number Diff line number Diff line change
@@ -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).
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? {
// 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
}
Comment on lines +17 to +20
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The W3C version check was changed from parts[0] == "00" (strict version-00-only) to parts[0] != "ff" (forward-compatible, per W3C spec). This is a behavioral change that's called out in the PR description, but no tests cover it. There should be tests verifying that: (1) a future version like "01" is accepted, and (2) version "ff" is rejected. The existing test suite covers W3C trace context propagation at line 771 but only tests with version "00".

Copilot uses AI. Check for mistakes.

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)
}
}
115 changes: 115 additions & 0 deletions Sources/SignozSwift/Vapor/SignozTracingMiddleware.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#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.
///
/// 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 SignozSwift
///
/// 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)
}
}
}
#endif