Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions:

jobs:
macOS:
runs-on: macos-15-intel
runs-on: macos-26-intel
steps:
Comment on lines +13 to 14
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.

PR metadata says this change is only a CI runner bump, but this PR also adds a new SignozVapor product/target, a new middleware implementation, W3C trace context refactor, and documentation updates. Please update the PR title/description (or split into separate PRs) so reviewers understand the full scope.

Copilot uses AI. Check for mistakes.
- uses: actions/checkout@v4
- uses: swift-actions/setup-swift@v2
Expand Down
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"
),
],
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
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
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 Expand Up @@ -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
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

Comment on lines +1 to +2
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.

This file calls trimmingCharacters(in: .whitespaces) (below) but does not import Foundation, which can cause a compile error since that API is provided by Foundation’s overlay. Add import Foundation at the top.

Copilot uses AI. Check for mistakes.
/// 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.
Comment on lines +12 to +16
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.

W3CTraceContext.parse/serialize introduces new behavior (forward-version support, ff rejection, extra-field handling) that isn’t directly covered by the existing propagation tests. Adding focused unit tests for these edge cases would help prevent regressions.

Copilot uses AI. Check for mistakes.
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
Comment on lines +18 to +28
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.

traceparent validation is too permissive for version 00: per W3C Trace Context, version 00 must have exactly 4 --delimited parts (no extra fields), and the version/flags fields must be 2 hex chars. Consider tightening the guards so malformed headers don’t get accepted as valid remote parents.

Suggested change
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
// Need at least 4 parts: version, trace-id, span-id, trace-flags.
guard parts.count >= 4 else {
return nil
}
let versionPart = parts[0]
// Version field must be exactly 2 hex chars and not "ff".
guard versionPart.count == 2,
UInt8(versionPart, radix: 16) != nil,
versionPart != "ff"
else {
return nil
}
// For version "00", the header must have exactly 4 parts (no extras).
if versionPart == "00" && parts.count != 4 {
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 flagsPart = parts[3]
// Trace-flags field must be exactly 2 hex chars.
guard flagsPart.count == 2,
let flagsByte = UInt8(flagsPart, radix: 16)
else {
return nil
}
let sampled = (flagsByte & 0x01) != 0

Copilot uses AI. Check for mistakes.
var traceFlags = TraceFlags()
traceFlags.setIsSampled(sampled)
Comment on lines +13 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n Sources/SignozSwift/Tracing/W3CTraceContext.swift

Repository: photon-hq/SignozSwift

Length of output: 3244


🌐 Web query:

W3C Trace Context specification traceparent header format validation requirements

πŸ’‘ Result:

traceparent format (Trace Context v1.0, version 00)

A traceparent header value must be (ABNF):
version "-" trace-id "-" parent-id "-" trace-flags, where all hex is lowercase (0-9a-f). [1]

  • version: exactly 2 lowercase hex chars; ff is forbidden/invalid. [1]
  • trace-id: exactly 32 lowercase hex chars (16 bytes); all-zero value is invalid (000…000). If invalid, vendors MUST ignore the traceparent. [1]
  • parent-id: exactly 16 lowercase hex chars (8 bytes); all-zero value is invalid (000…0000). If invalid, vendors MUST ignore the traceparent. [1]
  • trace-flags: exactly 2 lowercase hex chars (1 byte). For version 00, only the least-significant bit is defined (sampled); all other bits are reserved and vendors MUST set them to 0 when propagating. [1]

What to do when parsing/validation fails

  • If the version prefix can’t be parsed (not 2 hex + -), the implementation should restart the trace (i.e., create a new traceparent) and delete tracestate. [1]
  • If the version is higher than supported, the implementation SHOULD try to parse trace-id, parent-id, and the sampled bit using fixed positions/checks; if that parsing fails, restart the trace and delete tracestate; also set any unknown/unparsed flags to 0 on outgoing requests. [1]
  • If traceparent parsing fails, the vendor MUST NOT attempt to parse tracestate. [1]

Header name casing

  • Senders SHOULD use lowercase traceparent, but receivers MUST accept any case. [1]

Sources
[1] W3C Recommendation: Trace Context (Trace Context HTTP Headers Format / traceparent). (w3.org)

Citations:


🏁 Script executed:

rg -t swift "class TraceId|struct TraceId" --max-count=5 -A 10 -B 2

Repository: photon-hq/SignozSwift

Length of output: 47


🏁 Script executed:

rg "TraceId|SpanId" --max-count=10 -B 2 -A 5 | head -50

Repository: photon-hq/SignozSwift

Length of output: 1640


🏁 Script executed:

rg "import.*TraceId|import.*OpenTelemetry" --max-count=10

Repository: photon-hq/SignozSwift

Length of output: 2068


🏁 Script executed:

# Check if there are any tests for W3CTraceContext.parse to understand current behavior
rg "W3CTraceContext.parse|traceparent" -A 5 -B 2 Tests/

Repository: photon-hq/SignozSwift

Length of output: 2081


🏁 Script executed:

rg "Invalid traceparent|sampledBitmask" -A 10 Tests/SignozSwiftTests/SignozSwiftTests.swift

Repository: photon-hq/SignozSwift

Length of output: 777


🏁 Script executed:

# Check if TraceId and SpanId validate all-zeros case
rg "isValid|all.zero|00000000" Tests/SignozSwiftTests/ | grep -i "trace\|span" | head -20

Repository: photon-hq/SignozSwift

Length of output: 47


🏁 Script executed:

# Check the test for sampledBitmask to see if uppercase hex is tested
rg "traceparent.*03|traceparent.*[A-F]" Tests/

Repository: photon-hq/SignozSwift

Length of output: 196


🏁 Script executed:

# Look for any test cases with invalid flags or malformed traceparent
rg "@Test|func.*traceparent|func.*trace" Tests/SignozSwiftTests/SignozSwiftTests.swift -A 1 | grep -A 1 "@Test"

Repository: photon-hq/SignozSwift

Length of output: 3905


🏁 Script executed:

# Let's verify what happens with invalid hex in the flags field
cat > /tmp/test_parse.swift << 'EOF'
let flagsInvalid = "ZZ"
let result = UInt8(flagsInvalid, radix: 16)
print("Invalid hex 'ZZ': \(result ?? -1)") // Should be nil

let flagsValid = "03"
let result2 = UInt8(flagsValid, radix: 16)
print("Valid hex '03': \(result2 ?? -1)") // Should be 3

// Current implementation pattern
let sampled = UInt8(flagsInvalid, radix: 16).map { $0 & 0x01 != 0 } ?? false
print("Invalid flags default to sampled=false: \(sampled)")
EOF
# Can't run Swift directly, but we can verify the logic is correct
cat /tmp/test_parse.swift

Repository: photon-hq/SignozSwift

Length of output: 487


🏁 Script executed:

# Check if there's any validation of hex character length or format in the tests
rg "parts\[0\]|parts\[3\]|parts\.count" Sources/SignozSwift/Tracing/W3CTraceContext.swift

Repository: photon-hq/SignozSwift

Length of output: 209


Strengthen traceparent validation to comply with W3C Trace Context specification.

Line 18 uses string comparison (!= "ff") instead of hex validation, so it accepts uppercase variants like "FF" and doesn't reject invalid hex. Line 28 silently converts invalid flags to false instead of rejecting the header. The W3C spec requires exact 2-character hex fields with specific constraints, and vendors must ignore malformed traceparent headers entirely. Currently, invalid headers may still produce a SpanContext and propagate as remote parents downstream.

Suggested hardening
-        let parts = traceparent.split(separator: "-")
-        guard parts.count >= 4, parts[0] != "ff" else {
+        let parts = traceparent.split(separator: "-", omittingEmptySubsequences: false)
+        guard parts.count >= 4,
+              parts[0].count == 2,
+              let version = UInt8(String(parts[0]), radix: 16),
+              version != 0xff,
+              parts[3].count == 2,
+              let flags = UInt8(String(parts[3]), radix: 16),
+              !(version == 0x00 && parts.count != 4) 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
+        let sampled = flags & 0x01 != 0
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/SignozSwift/Tracing/W3CTraceContext.swift` around lines 13 - 30, In
parse(traceparent:tracestate:) strengthen validation: require parts.count >= 4
and validate each field's length and hex-ness (parts[0] must be exactly 2 hex
chars and not "ff" case-insensitive; parts[1] must be 32 hex chars for TraceId;
parts[2] must be 16 hex chars for SpanId; parts[3] must be exactly 2 hex chars
for flags), and if any of these checks fail return nil; parse the flags with a
failable UInt8 initializer and if it fails reject the header instead of
defaulting to false, and only construct/set traceFlags (using
TraceFlags.setIsSampled) after all validations succeed (use
TraceId(fromHexString:) and SpanId(fromHexString:) results only when their
inputs passed the explicit hex/length checks).


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