Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
78 changes: 75 additions & 3 deletions Package.resolved

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

15 changes: 15 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ let package = Package(
name: "SignozSwift",
targets: ["SignozSwift"]
),
.library(
name: "SignozVapor",
targets: ["SignozVapor"]
),
],
dependencies: [
.package(
Expand Down Expand Up @@ -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"
),
Comment thread
underthestars-zhy marked this conversation as resolved.
],
targets: [
.target(
Expand Down Expand Up @@ -99,6 +107,13 @@ let package = Package(
),
]
),
.target(
name: "SignozVapor",
dependencies: [
"SignozSwift",
.product(name: "Vapor", package: "vapor"),
]
),
.testTarget(
name: "SignozSwiftTests",
dependencies: [
Expand Down
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
```

Expand Down Expand Up @@ -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 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Signoz.start(serviceName: "my-vapor-api") {
Expand All @@ -58,22 +59,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
146 changes: 146 additions & 0 deletions Sources/SignozVapor/SignozTracingMiddleware.swift
Original file line number Diff line number Diff line change
@@ -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)")
Comment thread
underthestars-zhy marked this conversation as resolved.
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))
}
Comment on lines +25 to +53
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.

When no route is matched (e.g., 404 responses), the span name retains the concrete request path (e.g., GET /users/12345/foo/bar). This can cause a cardinality explosion in your trace backend since every unique URL path becomes a separate span name. Per OpenTelemetry HTTP semantic conventions, the span name should fall back to just the HTTP method (e.g., GET) when no route pattern is available. Consider updating the span name to just method (or "\(method) \(path)" only as a temporary initial name) and then after the responder chain, setting it to "\(method) \(pattern)" if a route exists, or just method if no route was matched.

Copilot uses AI. Check for mistakes.

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))
}
Comment thread
underthestars-zhy marked this conversation as resolved.
span.status = .error(description: "\(error)")
span.end()
throw error
Comment thread
underthestars-zhy marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

// 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)
}
}
}
Loading