Ergonomic OpenTelemetry wrapper for sending traces, logs, and metrics to SigNoz via OTLP/gRPC. One call to set up, top-level functions to instrument.
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/photon-hq/SignozSwift.git", from: "0.1.0"),
]Then add "SignozSwift" to your target's dependencies:
.target(
name: "MyApp",
dependencies: ["SignozSwift"]
),import SignozSwift
// Start instrumentation
Signoz.start(serviceName: "my-app") {
$0.environment = "production"
$0.serviceVersion = "1.0.0"
}
defer { Signoz.shutdown() }
// Trace
let result = try span("fetch-users", kind: .client) { s in
s.setAttribute(key: "db.system", value: "postgresql")
return try db.fetchUsers()
}
// Log
info("Request handled", attributes: ["status": 200])For Vapor projects, depend on the SignozVapor product instead of SignozSwift — it re-exports everything plus the tracing middleware:
.target(
name: "MyVaporApp",
dependencies: [
.product(name: "SignozVapor", package: "SignozSwift"),
]
),import SignozVapor
func configure(_ app: Application) throws {
Signoz.start(serviceName: "my-vapor-api") {
$0.endpoint = "ingest.signoz.io:4317" // or localhost:4317 for self-hosted
$0.transportSecurity = .tls
$0.headers = ["signoz-ingestion-key": "..."]
$0.environment = "production"
$0.serviceVersion = "1.0.0"
}
// 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
// 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.
import ArgumentParser
import SignozSwift
@main
struct MyCLI: AsyncParsableCommand {
@Option var input: String
func run() async throws {
Signoz.start(serviceName: "my-cli") {
$0.spanProcessing = .simple // flush immediately for short-lived CLI
}
defer { Signoz.shutdown() }
try await span("process-data") { s in
info("Starting", attributes: ["input": .string(input)])
let result = try await processData(input)
s.setAttribute(key: "records.processed", value: result.count)
}
}
}import SignozSwift
import SwiftUI
@main
struct MyApp: App {
init() {
Signoz.start(serviceName: "my-ios-app") {
$0.endpoint = "ingest.signoz.io:4317"
$0.transportSecurity = .tls
$0.headers = ["signoz-ingestion-key": "..."]
$0.environment = "production"
$0.autoInstrumentation.signpostIntegration = true
// urlSession + resourceDetection are ON by default
}
}
var body: some Scene {
WindowGroup { ContentView() }
}
}// Start — serviceName is required, everything else has defaults
Signoz.start(serviceName: "my-app") { config in
config.endpoint = "localhost:4317" // default
config.environment = "production" // deployment.environment
config.hostName = .auto // host.name (short system hostname, domain suffix stripped)
config.hostName = .custom("web-01") // host.name (explicit value)
// config.hostName = .none // omit host.name (default)
config.serviceVersion = "1.0.0"
config.transportSecurity = .plaintext // default
config.spanProcessing = .batch() // default, use .simple for CLIs
config.headers = ["signoz-ingestion-key": "..."]
config.resourceAttributes = ["custom.attr": "value"]
config.localPersistencePath = URL(filePath: "/tmp/signoz") // optional on-disk queue
config.consoleLog = .enabled // colored stderr output
}
// Shutdown — flush and clean up
Signoz.shutdown()// Sync span — auto-starts, auto-ends, auto-sets error status on throw
let result = try span("operation-name", kind: .client, attributes: [
"http.method": "GET",
"http.status_code": 200,
]) { s in
s.setAttribute(key: "extra", value: "value")
return try doWork()
}
// Async span
let data = try await span("fetch", kind: .client) { s async in
try await URLSession.shared.data(from: url)
}
// Nested spans
span("parent") { _ in
span("child") { _ in
info("Inside child span")
}
}
// Direct tracer access for advanced use
let s = Signoz.tracer.spanBuilder(spanName: "manual").startSpan()
s.end()trace("Verbose detail")
debug("Debug info", attributes: ["count": 42])
info("Request handled", attributes: ["status": 200])
warn("Approaching limit", attributes: ["threshold": 0.9])
error("Something failed", attributes: ["code": 500])
fatal("Unrecoverable error")
// Direct logger access
Signoz.logger.log("Custom", severity: .info, attributes: ["key": "value"])By default (.auto), log calls also print colored output to stderr in DEBUG builds — useful during development without extra setup. In RELEASE builds, console output is automatically disabled to avoid noise.
Signoz.start(serviceName: "my-app") {
$0.consoleLog = .enabled // always print to stderr
// $0.consoleLog = .disabled // never print to stderr
// $0.consoleLog = .auto // DEBUG only (default)
}
info("Server started", attributes: ["port": 8080])
// stderr: [INFO] Server started {"port": 8080}If your app uses gRPC (via grpc-swift-2), you can automatically trace all RPC calls by attaching the bundled interceptors from grpc-swift-extras:
import SignozSwift
import GRPCNIOTransportHTTP2
// Client — auto-creates a span for each outgoing RPC
let client = GRPCClient(
transport: try .http2NIOPosix(target: .dns(host: "api.example.com", port: 443)),
interceptors: [
ClientOTelTracingInterceptor(
serverHostname: "api.example.com",
networkTransportMethod: "tcp"
)
]
)
// Server — auto-creates a span for each incoming RPC
let server = GRPCServer(
transport: try .http2NIOPosix(address: .ipv4(host: "0.0.0.0", port: 8080)),
services: [myService],
interceptors: [
ServerOTelTracingInterceptor(
serverHostname: "api.example.com",
networkTransportMethod: "tcp"
)
]
)Each span is annotated with OTel semantic conventions (rpc.system, rpc.service, rpc.method, rpc.grpc.status_code, etc.) and context is automatically propagated via W3C traceparent headers.
AttributeValue conforms to ExpressibleByStringLiteral, ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral, and ExpressibleByBooleanLiteral, so you can write:
let attrs: [String: AttributeValue] = [
"name": "alice", // .string("alice")
"count": 42, // .int(42)
"ratio": 0.75, // .double(0.75)
"enabled": true, // .bool(true)
]| Property | Type | Default | Description |
|---|---|---|---|
endpoint |
String |
"localhost:4317" |
gRPC endpoint (host:port) |
serviceName |
String |
required | Service name (service.name) |
serviceVersion |
String |
"" |
Service version (service.version) |
environment |
String |
"" |
Deployment environment (deployment.environment) |
hostName |
.none | .auto | .custom(String) |
.none |
Host name (host.name). .none omits the attribute, .auto uses the short system hostname (strips domain suffixes like .local), .custom("...") uses an explicit value. |
resourceAttributes |
[String: AttributeValue] |
[:] |
Extra resource attributes |
headers |
[String: String] |
[:] |
gRPC metadata headers |
transportSecurity |
.plaintext | .tls |
.plaintext |
Transport security mode |
spanProcessing |
.simple | .batch(...) |
.batch() |
Span processing strategy |
localPersistencePath |
URL? |
nil |
Directory for a durable on-disk telemetry queue. When set, traces, logs, and metrics are buffered to disk and forwarded to the OTLP collector from disk. If the collector is unreachable the data is retained and replayed once connectivity resumes, so nothing is lost across a network outage. The directory (and its per-signal traces/logs/metrics subdirectories) is created automatically and stays near-empty in normal operation — it only accumulates files during an outage. |
consoleLog |
.auto | .enabled | .disabled |
.auto |
Colored console output to stderr. .auto enables in DEBUG builds only, .enabled always prints, .disabled never prints. |
autoInstrumentation |
AutoInstrumentation |
see below | Auto-instrumentation toggles |
| Property | Default | Description |
|---|---|---|
urlSession |
true |
Auto-trace URLSession calls (Apple platforms) |
resourceDetection |
true |
Auto-detect device/app/OS attributes (Apple platforms) |
signpostIntegration |
false |
Bridge spans to Xcode Instruments (Apple platforms) |
metricsShim |
true |
Bridge swift-metrics to OTel (enables Vapor HTTP metrics) |
| Configuration | Resource Attribute | SigNoz Filter |
|---|---|---|
serviceName |
service.name |
Services |
environment |
deployment.environment |
Logs > Environment |
hostName |
host.name |
Logs > Hostname |
SignozSwift wraps the official OpenTelemetry Swift SDK — it does not reinvent any OTel types.
- opentelemetry-swift-core 2.3.0 —
OpenTelemetryApi,OpenTelemetrySdk - opentelemetry-swift 3.0.0 — OTLP proto adapters, URLSession instrumentation, ResourceExtension, SignPost integration, SwiftMetricsShim
- grpc-swift-2 2.2.1 — gRPC transport (v2, async/await)
- grpc-swift-extras 2.1.1 — OTel tracing interceptors for automatic gRPC span injection
- Rainbow 4.x — Colored console output
All OTel types (Span, Tracer, Logger, AttributeValue, SpanKind, etc.) are re-exported via @_exported import OpenTelemetryApi, so you only need import SignozSwift.
Integration tests export telemetry via gRPC to localhost:4317. A local OpenTelemetry Collector must be running, otherwise each test will block waiting for gRPC timeouts (~60-240s per test).
Start the collector with Docker:
docker run -d --name otel-collector \
-p 4317:4317 \
-v $(pwd)/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml \
otel/opentelemetry-collector-contrib:latestThe collector's debug exporter logs all received telemetry. One integration test reads docker logs to verify spans and logs were actually received end-to-end.
Then run tests:
swift testStart/stop the collector between sessions:
docker start otel-collector
docker stop otel-collector- Swift 6.2+
- macOS 15+ / iOS 18+ / Linux
MIT