-
Notifications
You must be signed in to change notification settings - Fork 0
Add Vapor tracing middleware (SignozVapor) #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
4f749dd
be09ecb
6957f1e
2c6eefa
cc73520
a6e1c27
ca8cdfc
e940fbd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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
|
||
|
|
||
| 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) | ||
| } | ||
| } | ||
| 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 |
Uh oh!
There was an error while loading. Please reload this page.