Skip to content

Commit be0d91b

Browse files
Add example of metrics middleware using swift-metrics (#446)
### Motivation As we approach 1.0, we want more examples of how to use this package and integrate with other packages in the ecosystem. ### Modifications Add an example that uses swift-metrics and swift-prometheus to collect and emit metrics respectively. ### Result More examples. ### Test Plan ```console % docker-compose -f docker/docker-compose.yaml run -e SINGLE_EXAMPLE_PACKAGE=MetricsMiddleware examples ... ** Copying example MetricsMiddleware to /tmp/test-examples.sh.zgACTExtGC/MetricsMiddleware ... ** ✅ Successfully built the example package MetricsMiddleware. ```
1 parent 4a3c485 commit be0d91b

File tree

12 files changed

+402
-0
lines changed

12 files changed

+402
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.gitignore

Examples/MetricsMiddleware/.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.DS_Store
2+
.build
3+
/Packages
4+
/*.xcodeproj
5+
xcuserdata/
6+
DerivedData/
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.vscode
9+
/Package.resolved
10+
.ci/
11+
.docc-build/
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// swift-tools-version:5.9
2+
//===----------------------------------------------------------------------===//
3+
//
4+
// This source file is part of the SwiftOpenAPIGenerator open source project
5+
//
6+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
7+
// Licensed under Apache License v2.0
8+
//
9+
// See LICENSE.txt for license information
10+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
11+
//
12+
// SPDX-License-Identifier: Apache-2.0
13+
//
14+
//===----------------------------------------------------------------------===//
15+
import PackageDescription
16+
17+
let package = Package(
18+
name: "MetricsMiddleware",
19+
platforms: [.macOS(.v13)],
20+
dependencies: [
21+
.package(url: "https://github.com/apple/swift-openapi-generator", exact: "1.0.0-alpha.1"),
22+
.package(url: "https://github.com/apple/swift-openapi-runtime", exact: "1.0.0-alpha.1"),
23+
.package(url: "https://github.com/swift-server/swift-openapi-vapor", exact: "1.0.0-alpha.1"),
24+
.package(url: "https://github.com/vapor/vapor", from: "4.87.1"),
25+
.package(url: "https://github.com/apple/swift-metrics", from: "2.4.1"),
26+
.package(url: "https://github.com/swift-server/swift-prometheus", exact: "2.0.0-alpha.1"),
27+
],
28+
targets: [
29+
.target(
30+
name: "MetricsMiddleware",
31+
dependencies: [
32+
.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
33+
.product(name: "Metrics", package: "swift-metrics"),
34+
]
35+
),
36+
.executableTarget(
37+
name: "HelloWorldVaporServer",
38+
dependencies: [
39+
"MetricsMiddleware", .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"),
40+
.product(name: "OpenAPIVapor", package: "swift-openapi-vapor"),
41+
.product(name: "Vapor", package: "vapor"), .product(name: "Metrics", package: "swift-metrics"),
42+
.product(name: "Prometheus", package: "swift-prometheus"),
43+
],
44+
plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")]
45+
),
46+
]
47+
)

Examples/MetricsMiddleware/README.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# Metrics middleware using Swift Metrics and Swift Prometheus
2+
3+
In this example we'll implement a `ClientMiddleware` and `ServerMiddleware`
4+
that use `swift-metrics` and `swift-prometheus` to collect and emit metrics
5+
respectively.
6+
7+
## Overview
8+
9+
This example extends the [HelloWorldVaporServer](../HelloWorldVaporServer)
10+
with a new target, `MetricsMiddleware`, which is then used when creating
11+
the `Server`.
12+
13+
The metrics can now be accessed my making a HTTP GET request to `/metrics`.
14+
15+
## Testing
16+
17+
### Running the server and querying the metrics endpoint
18+
19+
First, in one terminal, start the server.
20+
21+
```console
22+
% swift run
23+
```
24+
25+
Then, in another terminal, make some requests:
26+
27+
```console
28+
% xargs -n1 -I% curl "localhost:8080/api/greet?name=%" <<< "Juan Mei Tom Bill Anne Ravi Maria"
29+
{
30+
"message" : "Hello, Juan!"
31+
}
32+
{
33+
"message" : "Hello, Mei!"
34+
}
35+
{
36+
"message" : "Hello, Tom!"
37+
}
38+
{
39+
"message" : "Hello, Bill!"
40+
}
41+
{
42+
"message" : "Hello, Anne!"
43+
}
44+
{
45+
"message" : "Hello, Ravi!"
46+
}
47+
{
48+
"message" : "Hello, Maria!"
49+
}
50+
```
51+
52+
Now you can query the `/metrics` endpoint of the server:
53+
54+
```console
55+
% curl "localhost:8080/metrics"
56+
# TYPE http_requests_total counter
57+
http_requests_total{method="GET",path="/metrics",status="200"} 1
58+
http_requests_total{method="GET",path="//api/greet",status="200"} 7
59+
# TYPE http_request_duration_seconds histogram
60+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="0.005"} 5
61+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="0.01"} 6
62+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="0.025"} 7
63+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="0.05"} 7
64+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="0.1"} 7
65+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="0.25"} 7
66+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="0.5"} 7
67+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="1.0"} 7
68+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="2.5"} 7
69+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="5.0"} 7
70+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="10.0"} 7
71+
http_request_duration_seconds_bucket{method="GET",path="//api/greet",status="200",le="+Inf"} 7
72+
http_request_duration_seconds_sum{method="GET",path="//api/greet",status="200"} 0.025902709
73+
http_request_duration_seconds_count{method="GET",path="//api/greet",status="200"} 7
74+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="0.005"} 1
75+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="0.01"} 1
76+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="0.025"} 1
77+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="0.05"} 1
78+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="0.1"} 1
79+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="0.25"} 1
80+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="0.5"} 1
81+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="1.0"} 1
82+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="2.5"} 1
83+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="5.0"} 1
84+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="10.0"} 1
85+
http_request_duration_seconds_bucket{method="GET",path="/metrics",status="200",le="+Inf"} 1
86+
http_request_duration_seconds_sum{method="GET",path="/metrics",status="200"} 0.001705458
87+
http_request_duration_seconds_count{method="GET",path="/metrics",status="200"} 1
88+
# TYPE HelloWorldServer.getGreeting.200 counter
89+
HelloWorldServer.getGreeting.200 7
90+
```
91+
92+
The response contains the Prometheus metrics. You should see `http_requests_total{status="200", path="/api/greet", method="GET"} 7` for the seven requests made in the previous step.
93+
94+
### Visualizing the metrics with Prometheus
95+
96+
We'll use [Compose](https://docs.docker.com/compose) to run a set of containers
97+
to collect and visualize the metrics.
98+
99+
The Compose file defines two services: `api` and `prometheus`. The `api`
100+
service uses an image built using the `Dockerfile` in the current directory.
101+
The `prometheus` service uses a public Prometheus image.
102+
103+
The `prometheus` service is configured using the `prometheus.yml` file in the
104+
current directory. This configures Prometheus to scrape the /metrics endpoint
105+
of the API server every 5 seconds.
106+
107+
Build and run the Compose application. You should see logging in the console
108+
from the API server and Prometheus.
109+
110+
> NOTE: You need to keep this terminal window open for the remaining steps. Pressing Ctrl-C will shut down the application.
111+
112+
```console
113+
% docker compose up
114+
[+] Building 12/122
115+
...
116+
[+] Running 2/0
117+
⠿ Container metricsmiddleware-prometheus-1 Created 0.0s
118+
⠿ Container metricsmiddleware-api-1 Created 0.0s
119+
...
120+
metricsmiddleware-api-1 | 2023-06-08T14:34:24+0000 notice codes.vapor.application : [Vapor] Server starting on http://0.0.0.0:8080
121+
...
122+
metricsmiddleware-prometheus-1 | ts=2023-06-08T14:34:24.914Z caller=web.go:562 level=info component=web msg="Start listening for connections" address=0.0.0.0:9090
123+
...
124+
```
125+
126+
At this point you can make requests to the server, as before:
127+
128+
```console
129+
% % echo "Juan Mei Tom Bill Anne Ravi Maria" | xargs -n1 -I% curl "localhost:8080/api/greet?name=%"
130+
{
131+
"message" : "Hello, Juan!"
132+
}
133+
{
134+
"message" : "Hello, Mei!"
135+
}
136+
{
137+
"message" : "Hello, Tom!"
138+
}
139+
{
140+
"message" : "Hello, Bill!"
141+
}
142+
{
143+
"message" : "Hello, Anne!"
144+
}
145+
{
146+
"message" : "Hello, Ravi!"
147+
}
148+
{
149+
"message" : "Hello, Maria!"
150+
}
151+
```
152+
153+
Now open the Prometheus UI in your web browser by visiting [localhost:9090](http://localhost:9090). Click the graph tab and update the query to `http_requests_total`, or use [this pre-canned link](http://localhost:9090/graph?g0.expr=http_requests_total&g0.tab=0&g0.stacked=0&g0.show_exemplars=0&g0.range_input=5m).
154+
155+
You should see the graph showing the seven recent requests.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import OpenAPIRuntime
15+
import OpenAPIVapor
16+
import Vapor
17+
import MetricsMiddleware
18+
import Metrics
19+
import Prometheus
20+
21+
struct Handler: APIProtocol {
22+
func getGreeting(_ input: Operations.getGreeting.Input) async throws -> Operations.getGreeting.Output {
23+
let name = input.query.name ?? "Stranger"
24+
return .ok(.init(body: .json(.init(message: "Hello, \(name)!"))))
25+
}
26+
}
27+
28+
@main struct HelloWorldVaporServer {
29+
static func main() throws {
30+
let registry = PrometheusCollectorRegistry()
31+
MetricsSystem.bootstrap(PrometheusMetricsFactory(registry: registry))
32+
33+
let app = Vapor.Application()
34+
35+
app.get("metrics") { request in
36+
var buffer: [UInt8] = []
37+
buffer.reserveCapacity(1024)
38+
registry.emit(into: &buffer)
39+
return String(decoding: buffer, as: UTF8.self)
40+
}
41+
42+
let transport = VaporTransport(routesBuilder: app)
43+
let handler = Handler()
44+
try handler.registerHandlers(
45+
on: transport,
46+
serverURL: URL(string: "/api")!,
47+
middlewares: [MetricsMiddleware(counterPrefix: "HelloWorldServer")]
48+
)
49+
50+
let host = ProcessInfo.processInfo.environment["HOST"] ?? "localhost"
51+
let port = ProcessInfo.processInfo.environment["PORT"].flatMap(Int.init) ?? 8080
52+
app.http.server.configuration.address = .hostname(host, port: port)
53+
try app.run()
54+
}
55+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
generate:
2+
- types
3+
- server
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
openapi: '3.1.0'
2+
info:
3+
title: GreetingService
4+
version: 1.0.0
5+
servers:
6+
- url: https://example.com/api
7+
description: Example service deployment.
8+
paths:
9+
/greet:
10+
get:
11+
operationId: getGreeting
12+
parameters:
13+
- name: name
14+
required: false
15+
in: query
16+
description: The name used in the returned greeting.
17+
schema:
18+
type: string
19+
responses:
20+
'200':
21+
description: A success response with a greeting.
22+
content:
23+
application/json:
24+
schema:
25+
$ref: '#/components/schemas/Greeting'
26+
components:
27+
schemas:
28+
Greeting:
29+
type: object
30+
description: A value with the greeting contents.
31+
properties:
32+
message:
33+
type: string
34+
description: The string representation of the greeting.
35+
required:
36+
- message
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftOpenAPIGenerator open source project
4+
//
5+
// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
import Foundation
15+
import HTTPTypes
16+
import Metrics
17+
import OpenAPIRuntime
18+
19+
package struct MetricsMiddleware {
20+
package var counterPrefix: String
21+
22+
package init(counterPrefix: String) { self.counterPrefix = counterPrefix }
23+
}
24+
25+
extension MetricsMiddleware: ClientMiddleware {
26+
package func intercept(
27+
_ request: HTTPRequest,
28+
body: HTTPBody?,
29+
baseURL: URL,
30+
operationID: String,
31+
next: (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
32+
) async throws -> (HTTPResponse, HTTPBody?) {
33+
do {
34+
let (response, responseBody) = try await next(request, body, baseURL)
35+
Counter(label: "\(counterPrefix).\(operationID).\(response.status.code.description)").increment()
36+
return (response, responseBody)
37+
} catch {
38+
Counter(label: "\(counterPrefix).\(operationID).error").increment()
39+
throw error
40+
}
41+
}
42+
}
43+
44+
extension MetricsMiddleware: ServerMiddleware {
45+
package func intercept(
46+
_ request: HTTPRequest,
47+
body: HTTPBody?,
48+
metadata: ServerRequestMetadata,
49+
operationID: String,
50+
next: (HTTPRequest, HTTPBody?, ServerRequestMetadata) async throws -> (HTTPResponse, HTTPBody?)
51+
) async throws -> (HTTPResponse, HTTPBody?) {
52+
func recordResult(_ result: String) { Counter(label: "\(counterPrefix).\(operationID).\(result)").increment() }
53+
do {
54+
let (response, responseBody) = try await next(request, body, metadata)
55+
Counter(label: "\(counterPrefix).\(operationID).\(response.status.code.description)").increment()
56+
return (response, responseBody)
57+
} catch {
58+
Counter(label: "\(counterPrefix).\(operationID).error").increment()
59+
throw error
60+
}
61+
}
62+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
FROM swift:5.9 AS builder
2+
COPY ../Sources/ /code/Sources/
3+
COPY ../Package.swift /code/Package.swift
4+
WORKDIR /code
5+
RUN swift build -c release
6+
7+
FROM swift:5.9-slim AS runtime
8+
COPY --from=builder /code/.build/release/HelloWorldVaporServer /HelloWorldVaporServer
9+
ENV HOST=0.0.0.0
10+
ENV PORT=8080
11+
ENTRYPOINT [ "/HelloWorldVaporServer" ]

0 commit comments

Comments
 (0)