I agree that improving the testing story for swift-metrics is valuable. However, I have significant concerns about the patterns this proposal encourages and the signal it sends to users of the library.
1. Metric creation belongs at startup, not at request time
The motivating examples in the proposal show metrics being created in request-handling code:
// Example that uses the globally hooked bootstrap
struct UserService {
func createUser(name: String) async throws -> User {
let counter = Counter(label: "users.created") // โ This creates a Counter at request invocation
let user = User()
counter.increment()
return user
}
}
// Example that uses a provided MetricsFactory
struct UserService {
let factory: MetricsFactory // โ Required for dependency injection
func createUser(name: String) async throws -> User {
let counter = Counter(label: "users.created", factory: factory) // โ This creates a Counter at request invocation
let user = User()
counter.increment()
return user
}
}
Every call to Counter(label:dimensions:) calls through to the factory's makeCounter(label:dimensions:). In real-world backends (Prometheus, StatsD, etc.), this is a dictionary lookup behind a lock - the factory acts as a deduplication cache. When you create counters at request time rather than at startup, every single request pays the cost of this locked lookup.
The correct pattern for metrics is to create them once at startup and then only call the cheap operations (increment(), record()) on the hot path:
struct UserService {
let counter: Counter
init(metricsFactory: any MetricsFactory) {
self.counter = Counter(label: "users.created", factory: metricsFactory) // โ
counter created once at startup
}
func createUser(name: String) async throws -> User {
let user = User()
self.counter.increment() // ๐ cheap: no lock, no lookup
return user
}
}
Interestingly, the proposal switches to creating the metrics at the correct time when the new Task local approach is used:
struct UserService {
let counter = Counter(label: "users.created") // โ
Gets factory from the task-local storage
func createUser(name: String) async throws -> User {
let user = User()
counter.increment()
return user
}
}
@Test
func testUserCreation() async throws {
let testMetrics1 = TestMetrics()
try await withMetricsFactory(changingFactory: testMetrics1) {
let service = UserService()
return try await service.createUser(name: "Alice")
}
#expect(try testMetrics1.expectCounter("users.created").values == [1])
}
However, the metrics factory in the task-local is also present at the request invocation time. It is likely that we would see this pattern in user code:
struct UserService {
func createUser(name: String) async throws -> User {
let user = User()
// โ Gets factory from the task-local storage
// Needs to go through global lock to get counter out of the backend
Counter(label: "users.created").increment()
return user
}
}
If we want to make the task-local pattern safe, developers should pay attention that their code looks like this:
let service = try await withMetricsFactory(changingFactory: testMetrics1) {
UserService() // โ
Only provide metrics factory to service during init
}
2. Dynamic labels and unbounded cardinality: a security concern
Metrics are fundamentally different from logging and tracing in one critical way: metrics must retain allocated state across flushes. A logger can flush its buffer and free memory. A tracing system can complete a span and release it. But a counter or gauge, once created with a given label+dimensions combination, must persist - the backend needs to continue reporting it to the monitoring system.
This makes unbounded metric cardinality a denial-of-service vector. If an attacker can cause metric creation with arbitrary dimension values (e.g., request paths, user IDs, query parameters), they can exhaust server memory.
This is not theoretical. Vapor had CVE-2021-21328 for exactly this issue: an attacker could send requests to arbitrary undefined URL paths, each creating new counters and timers with unique labels, eventually draining system resources and impacting downstream monitoring services. The fix was to rewrite undefined route paths to a static vapor_route_undefined label.
Prometheus documents this explicitly:
CAUTION: Remember that every unique combination of key-value label pairs represents a new time series, which can dramatically increase the amount of data stored. Do not use labels to store dimensions with high cardinality (many different label values), such as user IDs, email addresses, or other unbounded sets of values.
3. The task-local pattern sends the wrong signal
The task-local factory pattern mirrors how swift-log and swift-distributed-tracing work, where withSpan and logger metadata are scoped to tasks. But this analogy is misleading:
- Logging: Each log line is emitted and can be flushed. Memory cost is transient.
- Tracing: Each span has a defined lifecycle - it starts and ends. Memory is reclaimed after export.
- Metrics: Each unique counter/gauge/timer persists for the lifetime of the process. Memory cost is permanent.
By presenting withMetricsFactory as analogous to withSpan, the proposal suggests that metrics creation is similarly lightweight and safe to do dynamically. Users who are familiar with logging and tracing patterns will naturally assume they can create metrics with dynamic dimension values inside request handlers. This signal can be fatal for production systems.
4. The only responsible way to use metrics is with a restricted set of values
If all dimension values are from a restricted, known set, then all metric combinations can be enumerated and created once at startup. This is the pattern that should be encouraged:
// Known set of HTTP methods - safe
enum HTTPMethod: String, CaseIterable { case GET, POST, PUT, DELETE }
// Create all combinations at startup
let requestCounters: [HTTPMethod: Counter] = Dictionary(
uniqueKeysWithValues: HTTPMethod.allCases.map { method in
(method, Counter(label: "http.requests", dimensions: [("method", method.rawValue)]))
}
)
// On the hot path: just increment
func handleRequest(method: HTTPMethod) {
requestCounters[method]?.increment() // no lock, no lookup, no allocation
}
If metrics are created at startup with a known set of dimensions, there is no need for injecting the MetricsFactory through task-locals at request time.
5. Practical concern: will users follow the discipline?
One could argue that developers could use withMetricsFactory responsibly - only during startup, to set up a test factory before creating metrics. But in practice, the API's design naturally leads to creating metrics inside the scope, at request time. The similarity to withSpan from swift-distributed-tracing and metadata propagation in swift-log reinforces this: developers familiar with those patterns will assume the same scoping is safe for metrics. The examples in the proposal itself demonstrate this pattern. If the proposal's own examples show the anti-pattern, we should expect users to follow suit.
What I'd suggest instead
The testing problem is real, and I think we should solve it. But I believe we should:
-
Improve documentation: We need better guidance in swift-metrics about the correct pattern for metric creation - create once at startup, use on the hot path. This is a gap today regardless of this proposal.
-
Solve testing without encouraging request-scoped creation: The explicit factory parameter (Counter(label:dimensions:factory:)) already exists. Passing a MetricsFactory to services at init time - so they can create their counters, timers, and gauges up front - may not be as burdensome as the proposal suggests. It naturally enforces the "create once at startup" pattern and solves the testing story implicitly: in tests you pass a TestMetrics factory, in production you pass the real one. No task-local magic needed. This also solves the parallel test execution problem that motivates much of the proposal - each test simply creates its own service with its own factory:
@Test
func testUserCreation() async throws {
let testMetrics = TestMetrics()
let service = UserService(metricsFactory: testMetrics) // isolated factory, no global state
_ = try await service.createUser(name: "Alice")
#expect(try testMetrics.expectCounter("users.created").values == [1])
}
// Runs in parallel with no conflicts - each test has its own factory
@Test
func testAnotherUser() async throws {
let testMetrics = TestMetrics()
let service = UserService(metricsFactory: testMetrics)
_ = try await service.createUser(name: "Bob")
#expect(try testMetrics.expectCounter("users.created").values == [1])
}
-
If we do add task-local factory support, the documentation and examples must prominently warn against creating metrics on the hot path, and the examples should show the factory being used only during initialization, not per-request.