Skip to content

Commit 30b5095

Browse files
committed
feat: add engine-free request metrics policy
1 parent ccd3ffb commit 30b5095

7 files changed

Lines changed: 228 additions & 0 deletions

Package.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// swift-tools-version: 6.3.3
2+
3+
import PackageDescription
4+
5+
let package = Package(
6+
name: "swift-metrics",
7+
platforms: [
8+
.macOS(.v26),
9+
.iOS(.v26),
10+
.tvOS(.v26),
11+
.watchOS(.v26),
12+
.visionOS(.v26),
13+
],
14+
products: [
15+
.library(name: "Metrics", targets: ["Metrics"]),
16+
],
17+
targets: [
18+
.target(name: "Metrics"),
19+
.testTarget(name: "Metrics Tests", dependencies: ["Metrics"]),
20+
],
21+
swiftLanguageModes: [.v6]
22+
)
23+
24+
for target in package.targets where ![.system, .binary, .plugin, .macro].contains(target.type) {
25+
target.swiftSettings = [
26+
.strictMemorySafety(),
27+
.enableUpcomingFeature("ExistentialAny"),
28+
.enableUpcomingFeature("InternalImportsByDefault"),
29+
.enableUpcomingFeature("MemberImportVisibility"),
30+
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
31+
.enableExperimentalFeature("LifetimeDependence"),
32+
.enableExperimentalFeature("Lifetimes"),
33+
.enableExperimentalFeature("SuppressedAssociatedTypes"),
34+
.enableUpcomingFeature("InferIsolatedConformances"),
35+
]
36+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
extension Metrics.Request.Event {
2+
/// Adapter-neutral importance assigned by the request policy.
3+
public enum Severity: Equatable, Sendable {
4+
case trace
5+
case info
6+
case warning
7+
case error
8+
}
9+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
extension Metrics.Request {
2+
/// The canonical event emitted for a completed request.
3+
public struct Event: Equatable, Sendable {
4+
public let name: String
5+
public let method: String
6+
public let path: String
7+
public let statusCode: Int
8+
public let duration: Duration
9+
public let requestID: String?
10+
public let isStaticFile: Bool
11+
public let severity: Severity
12+
13+
internal init(request: Metrics.Request, severity: Severity, isStaticFile: Bool) {
14+
self.name = "http.request"
15+
self.method = request.method
16+
self.path = request.path
17+
self.statusCode = request.statusCode
18+
self.duration = request.duration
19+
self.requestID = request.requestID
20+
self.isStaticFile = isStaticFile
21+
self.severity = severity
22+
}
23+
}
24+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
extension Metrics.Request {
2+
/// Pure policy for turning completed request facts into one metrics event.
3+
public struct Policy: Sendable {
4+
public let detectStaticFiles: Bool
5+
6+
public init(detectStaticFiles: Bool = true) {
7+
self.detectStaticFiles = detectStaticFiles
8+
}
9+
10+
public func event(for request: Metrics.Request) -> Event {
11+
let isStaticFile = detectStaticFiles && Self.isStaticFile(request)
12+
return Event(
13+
request: request,
14+
severity: Self.severity(for: request.statusCode, isStaticFile: isStaticFile),
15+
isStaticFile: isStaticFile
16+
)
17+
}
18+
19+
private static func severity(
20+
for statusCode: Int,
21+
isStaticFile: Bool
22+
) -> Event.Severity {
23+
if isStaticFile || statusCode == 304 {
24+
return .trace
25+
}
26+
switch statusCode {
27+
case 500...:
28+
return .error
29+
30+
case 400..<500:
31+
return .warning
32+
33+
default:
34+
return .info
35+
}
36+
}
37+
38+
private static func isStaticFile(_ request: Metrics.Request) -> Bool {
39+
guard request.method == "GET" || request.method == "HEAD" else { return false }
40+
guard request.statusCode == 200 || request.statusCode == 304 else { return false }
41+
42+
if let contentType = request.contentType {
43+
let staticContentTypes = [
44+
"image/", "text/css", "application/javascript", "font/", "video/", "audio/",
45+
]
46+
if staticContentTypes.contains(where: contentType.hasPrefix) {
47+
return true
48+
}
49+
}
50+
51+
let staticExtensions = [
52+
".css", ".js", ".jpg", ".jpeg", ".png", ".gif", ".svg", ".ico",
53+
".woff", ".woff2", ".ttf",
54+
]
55+
return staticExtensions.contains(where: request.path.hasSuffix)
56+
}
57+
}
58+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
extension Metrics {
2+
/// The adapter-neutral facts available when a request has completed.
3+
public struct Request: Equatable, Sendable {
4+
public let method: String
5+
public let path: String
6+
public let statusCode: Int
7+
public let duration: Duration
8+
public let requestID: String?
9+
public let contentType: String?
10+
11+
public init(
12+
method: String,
13+
path: String,
14+
statusCode: Int,
15+
duration: Duration,
16+
requestID: String? = nil,
17+
contentType: String? = nil
18+
) {
19+
self.method = method
20+
self.path = path
21+
self.statusCode = statusCode
22+
self.duration = duration
23+
self.requestID = requestID
24+
self.contentType = contentType
25+
}
26+
}
27+
}

Sources/Metrics/Metrics.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/// Engine-free measurement and event policy for application metrics.
2+
public enum Metrics {
3+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import Testing
2+
3+
@testable import Metrics
4+
5+
@Suite("Request metrics policy")
6+
struct RequestPolicyTests {
7+
@Test("emits the canonical request event")
8+
func event() {
9+
let request = Metrics.Request(
10+
method: "GET",
11+
path: "/assets/app.js",
12+
statusCode: 200,
13+
duration: .milliseconds(42),
14+
requestID: "abc",
15+
contentType: "application/javascript"
16+
)
17+
18+
let event = Metrics.Request.Policy().event(for: request)
19+
20+
#expect(event.name == "http.request")
21+
#expect(event.method == "GET")
22+
#expect(event.path == "/assets/app.js")
23+
#expect(event.statusCode == 200)
24+
#expect(event.duration == .milliseconds(42))
25+
#expect(event.requestID == "abc")
26+
#expect(event.isStaticFile)
27+
#expect(event.severity == .trace)
28+
}
29+
30+
@Test("classifies response status without an engine")
31+
func severity() {
32+
let policy = Metrics.Request.Policy(detectStaticFiles: false)
33+
34+
#expect(policy.event(for: request(statusCode: 304)).severity == .trace)
35+
#expect(policy.event(for: request(statusCode: 404)).severity == .warning)
36+
#expect(policy.event(for: request(statusCode: 503)).severity == .error)
37+
#expect(policy.event(for: request(statusCode: 201)).severity == .info)
38+
}
39+
40+
@Test("uses the extension fallback for static files")
41+
func staticFileExtensionFallback() {
42+
let request = Metrics.Request(
43+
method: "HEAD",
44+
path: "/assets/site.css",
45+
statusCode: 304,
46+
duration: .zero
47+
)
48+
49+
let event = Metrics.Request.Policy().event(for: request)
50+
51+
#expect(event.isStaticFile)
52+
#expect(event.severity == .trace)
53+
}
54+
55+
@Test("does not classify non-GET requests as static files")
56+
func staticFileMethodGuard() {
57+
let request = Metrics.Request(
58+
method: "POST",
59+
path: "/assets/site.css",
60+
statusCode: 200,
61+
duration: .zero,
62+
contentType: "text/css"
63+
)
64+
65+
#expect(!Metrics.Request.Policy().event(for: request).isStaticFile)
66+
}
67+
68+
private func request(statusCode: Int) -> Metrics.Request {
69+
Metrics.Request(method: "GET", path: "/", statusCode: statusCode, duration: .milliseconds(1))
70+
}
71+
}

0 commit comments

Comments
 (0)