The instrumentation implements the OpenTelemetry GenAI semantic conventions, so model traffic looks the same whichever provider served it — which is the point of the library, extended to your observability stack.
Metrics#
| Name | Meaning |
|---|---|
gen_ai.client.token.usage | Tokens consumed, split by gen_ai.token.type (input / output) |
gen_ai.client.operation.duration | How long an operation took |
Attributes#
| Attribute | Value |
|---|---|
gen_ai.operation.name | complete, stream, stream_end, or models |
gen_ai.provider.name | The adapter that was called |
gen_ai.request.model | The model that was asked for |
gen_ai.response.model | The model that actually answered |
gen_ai.response.id | The provider's response identifier |
gen_ai.response.finish_reasons | Why generation ended |
gen_ai.usage.input_tokens | Input tokens, cache included |
gen_ai.usage.output_tokens | Output tokens |
gen_ai.request.temperature | When set |
gen_ai.request.top_p | When set |
gen_ai.request.max_tokens | When set |
gen_ai.token.type | input or output, on the usage metric |
error.type | On a failed operation |
Caveats
- Group by
gen_ai.response.model, notgen_ai.request.model. Aliases resolve to dated snapshots, so grouping by the request merges two models with different pricing into one line. - No prompt or completion content is recorded, deliberately.
- Names are spelled out in the package rather than taken from a
semconvmodule: the GenAI conventions are still in development, and pinning a semconv module would tie skyl's release cadence to theirs. ScopeNameisgithub.com/BAGOMBEKA-JOB-DEV/skyl/otel.
Usage#
Token spend by model
sum by (gen_ai_response_model, gen_ai_token_type) (
rate(gen_ai_client_token_usage_sum[5m])
) * 60sum by (gen_ai_response_model, gen_ai_token_type) (
rate(gen_ai_client_token_usage_sum[5m])
) * 60Error rate by provider
sum by (gen_ai_provider_name, error_type) (
rate(gen_ai_client_operation_duration_count{error_type!=""}[5m])
)sum by (gen_ai_provider_name, error_type) (
rate(gen_ai_client_operation_duration_count{error_type!=""}[5m])
)Latency percentiles
histogram_quantile(0.95,
sum by (le, gen_ai_provider_name) (
rate(gen_ai_client_operation_duration_bucket{gen_ai_operation_name="complete"}[5m])
)
)histogram_quantile(0.95,
sum by (le, gen_ai_provider_name) (
rate(gen_ai_client_operation_duration_bucket{gen_ai_operation_name="complete"}[5m])
)
)Filtering to complete matters: stream measures only the handshake, so mixing
them makes the percentile meaningless.
Detecting abandoned streams
# stream_end operations that carry no output tokens — a client hung up.
sum by (gen_ai_provider_name) (
rate(gen_ai_client_operation_duration_count{gen_ai_operation_name="stream_end"}[5m])
)# stream_end operations that carry no output tokens — a client hung up.
sum by (gen_ai_provider_name) (
rate(gen_ai_client_operation_duration_count{gen_ai_operation_name="stream_end"}[5m])
)Troubleshooting#
gen_ai.response.model is missing
Best-effort — a provider that does not report the serving model leaves it empty.
Fall back to gen_ai.request.model in your query.
Token metrics are absent for streaming
They arrive on stream_end, and require the upstream host to report usage. On
OpenAI-family hosts that means stream_options.include_usage must be honoured.
The attribute names changed under me
The GenAI conventions are still in development. skyl spells the keys out rather than pinning a semconv module, precisely so a convention change is a small, visible correction here rather than a forced dependency bump.