Observability
Metrics and traces are middleware, and the same middleware runs at either end of a call. An Around
sees one Exchange: which Side it is on, the Request, and on a server the route that matched.
routes @@ Middleware.around(a) runs it on a server. client @@ a (or the Client.around(a)
layer) runs it on a client. a ++ b runs a outside b.
A server hands an Around the route's template (/users/{id}), never the raw path, so a label or a
span name cannot grow one series per user. A request no route matched is a miss, and a miss is not
an exchange: the middleware never sees it, and a later ++ still gets its turn. A 405 is the same.
One value, both ends
next carries the request on, to the route or to the network, and it may be handed a changed one.
This Around stamps a header and records where it ran. The client and the server it calls each run
it once.
for
seen <- Ref.make(Chunk.empty[(Side, Option[String])])
stamp = new Around[Any]:
def apply[R1, E](exchange: Exchange)(next: Request => ZIO[R1, E, Response]) =
seen.update(_ :+ (exchange.side -> exchange.route.map(_.render))) *>
next(exchange.request.withHeader("X-Stamped", "yes"))
users = Routes(
Method.GET / "users" / int("id") handle { (id, req) =>
ZIO.succeed(Response.text(s"$id ${req.header("X-Stamped").getOrElse("no")}"))
}
) @@ Middleware.around(stamp)
body <- ZIO
.serviceWithZIO[Client](_.batched(Request.get("http://users.internal/users/7")))
.flatMap(_.body.utf8)
.provideLayer(Client.inMemory(users) >>> Client.around(stamp))
all <- seen.get
yield (body, all)(7 yes,Chunk((Client,None),(Server,Some(GET /users/{id}))))Metrics
Middleware.metrics() keeps zio-http's three server metrics through zio.metrics, so a dashboard
built for zio-http reads a heddle server unchanged: http_requests_total (a counter),
http_concurrent_requests_total (a gauge of requests in flight), and http_request_duration_seconds (a
histogram in seconds). Labels are method, path (the template), and status.
A client keeps the same three under http_client_requests_total, http_client_concurrent_requests_total, and
http_client_request_duration_seconds, with host in place of path. Around.metrics() is one value for
both: it picks the names by Side.
status is the response's code. An effect that fails with a Response reports that response's
code, and any other failure (a ClientError, a typed domain error, an interruption) reports
failed. The in-flight gauge comes back down on every exit, interruption included.
{
val users = Routes(Method.GET / "users" / int("id") -> { id => ZIO.succeed(Response.text(id.toString)) })
val app = users @@ Middleware.metrics()
val requests = Metric
.counterInt(server.requestsTotal)
.tagged(MetricLabel("method", "GET"), MetricLabel("path", "/users/{id}"), MetricLabel("status", "200"))
for
before <- requests.value
_ <- app(Request.get("/users/1")) *> app(Request.get("/users/2"))
after <- requests.value
yield after.count - before.count
}2.0extraLabels rides on every metric (a service name, a region). boundaries replaces the histogram
buckets. Around.MetricNames renames all three when two servers share one registry.
Tracing
OpenTelemetry is JVM-only, so spans are their own artifact:
libraryDependencies += "rocks.earlyeffect" %% "heddle-opentelemetry" % "0.11.0"
HeddleTracing.around(tracing) is an Around over zio-opentelemetry's Tracing, and
HeddleTracing.live reads it from the environment. A server opens a SERVER span named for the
route (GET /users/{id}) and continues the caller's trace from its traceparent. A client opens a
CLIENT span named for the method and sends its context on as traceparent. Spans carry
OpenTelemetry's HTTP attribute names: http.request.method, http.route, url.path, url.full,
server.address, and http.response.status_code. A server marks a 5xx as an error, a client a 4xx
or a 5xx, and either one marks an effect that failed.
Here a traced client calls a traced server. The two spans are one trace, and the server's parent is
the client's span. The exporter is in memory so the page can read it; in production Tracing comes
from your OpenTelemetry SDK or agent.
{
val spans = InMemorySpanExporter.create()
val provider = SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(spans)).build()
val tracing = ZLayer.make[Tracing](
ZLayer.succeed(provider.get("docs")),
Tracing.live(),
OpenTelemetry.contextZIO,
)
val users = Routes(Method.GET / "users" / int("id") -> { id => ZIO.succeed(Response.text(id.toString)) })
val call =
for
traced <- HeddleTracing.live
_ <- ZIO
.serviceWithZIO[Client](_.batched(Request.get("http://users.internal/users/7")))
.provideSomeLayer[Tracing](
Client.inMemory(users @@ Middleware.around(traced)) >>> Client.around(traced)
)
yield Chunk.fromIterable(spans.getFinishedSpanItems.asScala)
call.provideLayer(tracing)
}Chunk(SpanData{spanContext=ImmutableSpanContext{traceId=ad9cac83ed4c8948603d16c5bb1c895f, spanId=1740914478d3fe06, traceFlags=03, traceState=ArrayBasedTraceState{entries=[]}, remote=false, valid=true}, parentSpanContext=ImmutableSpanContext{traceId=ad9cac83ed4c8948603d16c5bb1c895f, spanId=e7100117934156b7, traceFlags=03, traceState=ArrayBasedTraceState{entries=[]}, remote=true, valid=true}, resource=Resource{schemaUrl=null, attributes={service.name="unknown_service:java", telemetry.sdk.language="java", telemetry.sdk.name="opentelemetry", telemetry.sdk.version="1.63.0"}}, instrumentationScopeInfo=InstrumentationScopeInfo{name=docs, version=null, schemaUrl=null, attributes={}}, name=GET /users/{id}, kind=SERVER, startEpochNanos=1793483801987505974, endEpochNanos=1793483801989505371, attributes=AttributesMap{data={http.response.status_code=200, http.request.method=GET, http.route=/users/{id}, url.path=/users/7}, capacity=128, totalAddedValues=4}, totalAttributeCount=4, events=[], totalRecordedEvents=0, links=[], totalRecordedLinks=0, status=ImmutableStatusData{statusCode=UNSET, description=}, hasEnded=true},SpanData{spanContext=ImmutableSpanContext{traceId=ad9cac83ed4c8948603d16c5bb1c895f, spanId=e7100117934156b7, traceFlags=03, traceState=ArrayBasedTraceState{entries=[]}, remote=false, valid=true}, parentSpanContext=ImmutableSpanContext{traceId=00000000000000000000000000000000, spanId=0000000000000000, traceFlags=00, traceState=ArrayBasedTraceState{entries=[]}, remote=false, valid=false}, resource=Resource{schemaUrl=null, attributes={service.name="unknown_service:java", telemetry.sdk.language="java", telemetry.sdk.name="opentelemetry", telemetry.sdk.version="1.63.0"}}, instrumentationScopeInfo=InstrumentationScopeInfo{name=docs, version=null, schemaUrl=null, attributes={}}, name=GET, kind=CLIENT, startEpochNanos=1793483801965053007, endEpochNanos=1793483801991126755, attributes=AttributesMap{data={url.full=http://users.internal/users/7, http.response.status_code=200, http.request.method=GET, server.address=users.internal}, capacity=128, totalAddedValues=4}, totalAttributeCount=4, events=[], totalRecordedEvents=0, links=[], totalRecordedLinks=0, status=ImmutableStatusData{statusCode=UNSET, description=}, hasEnded=true})Order
Apply tracing outside metrics when both are on, so the span covers the time the metric records:
routes @@ Middleware.around(HeddleTracing.around(tracing) ++ Around.metrics()). On a client the
same value goes on client @@.