From cc2a1b8bcbb7ea37b317bddf818be54395675d58 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 06:39:21 +0000 Subject: [PATCH] Add observability module: Spring AI built-in meters and spans, cost per endpoint from token usage, Prometheus and Grafana dashboard Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JXVi2GMQ7bR5EmbUFdDj7N --- README.md | 1 + observability/.gitignore | 1 + observability/README.md | 55 ++++ observability/dashboards/screenshot.png | Bin 0 -> 142315 bytes .../dashboards/spring-ai-observability.json | 308 ++++++++++++++++++ observability/output/01-builtin-metrics.txt | 16 + observability/output/02-tool-call-spans.txt | 24 ++ observability/output/03-cost-per-endpoint.txt | 10 + observability/output/04-streaming-usage.txt | 9 + observability/output/05-error-and-retry.txt | 8 + observability/output/06-prompt-content.txt | 7 + observability/output/07-histogram.txt | 7 + observability/output/08-dashboard-queries.txt | 12 + .../output/09-prometheus-families.txt | 26 ++ observability/pom.xml | 86 +++++ observability/scripts/load.sh | 13 + observability/scripts/make-dashboard.py | 49 +++ observability/scripts/run-all.sh | 13 + observability/scripts/stack-down.sh | 4 + observability/scripts/stack-up.sh | 27 ++ observability/scripts/verify-dashboard.py | 38 +++ .../ankurm/observability/AiController.java | 47 +++ .../observability/EndpointTagConvention.java | 22 ++ .../ObservabilityApplication.java | 14 + .../observability/ObservabilityConfig.java | 20 ++ .../com/ankurm/observability/Pricing.java | 25 ++ .../UsageCostObservationHandler.java | 57 ++++ .../ankurm/observability/WeatherTools.java | 14 + .../observability/fake/FakeOpenAiServer.java | 158 +++++++++ .../src/main/resources/application-stack.yml | 11 + .../src/main/resources/application.yml | 31 ++ .../observability/BuiltInMetricsTest.java | 63 ++++ .../observability/CostPerEndpointTest.java | 67 ++++ .../observability/ErrorAndRetryTest.java | 54 +++ .../ankurm/observability/HistogramTest.java | 61 ++++ .../observability/PromptContentTest.java | 80 +++++ .../observability/StreamingUsageTest.java | 59 ++++ .../observability/ToolCallSpansTest.java | 74 +++++ .../ankurm/observability/support/Fake.java | 23 ++ .../com/ankurm/observability/support/Obs.java | 47 +++ .../ankurm/observability/support/TestObs.java | 22 ++ .../observability/support/Transcript.java | 47 +++ .../grafana/provisioning/dashboards/ai.yml | 6 + .../grafana/provisioning/datasources/prom.yml | 8 + observability/stack/prometheus.yml | 7 + 45 files changed, 1731 insertions(+) create mode 100644 observability/.gitignore create mode 100644 observability/README.md create mode 100644 observability/dashboards/screenshot.png create mode 100644 observability/dashboards/spring-ai-observability.json create mode 100644 observability/output/01-builtin-metrics.txt create mode 100644 observability/output/02-tool-call-spans.txt create mode 100644 observability/output/03-cost-per-endpoint.txt create mode 100644 observability/output/04-streaming-usage.txt create mode 100644 observability/output/05-error-and-retry.txt create mode 100644 observability/output/06-prompt-content.txt create mode 100644 observability/output/07-histogram.txt create mode 100644 observability/output/08-dashboard-queries.txt create mode 100644 observability/output/09-prometheus-families.txt create mode 100644 observability/pom.xml create mode 100755 observability/scripts/load.sh create mode 100755 observability/scripts/make-dashboard.py create mode 100755 observability/scripts/run-all.sh create mode 100755 observability/scripts/stack-down.sh create mode 100755 observability/scripts/stack-up.sh create mode 100755 observability/scripts/verify-dashboard.py create mode 100644 observability/src/main/java/com/ankurm/observability/AiController.java create mode 100644 observability/src/main/java/com/ankurm/observability/EndpointTagConvention.java create mode 100644 observability/src/main/java/com/ankurm/observability/ObservabilityApplication.java create mode 100644 observability/src/main/java/com/ankurm/observability/ObservabilityConfig.java create mode 100644 observability/src/main/java/com/ankurm/observability/Pricing.java create mode 100644 observability/src/main/java/com/ankurm/observability/UsageCostObservationHandler.java create mode 100644 observability/src/main/java/com/ankurm/observability/WeatherTools.java create mode 100644 observability/src/main/java/com/ankurm/observability/fake/FakeOpenAiServer.java create mode 100644 observability/src/main/resources/application-stack.yml create mode 100644 observability/src/main/resources/application.yml create mode 100644 observability/src/test/java/com/ankurm/observability/BuiltInMetricsTest.java create mode 100644 observability/src/test/java/com/ankurm/observability/CostPerEndpointTest.java create mode 100644 observability/src/test/java/com/ankurm/observability/ErrorAndRetryTest.java create mode 100644 observability/src/test/java/com/ankurm/observability/HistogramTest.java create mode 100644 observability/src/test/java/com/ankurm/observability/PromptContentTest.java create mode 100644 observability/src/test/java/com/ankurm/observability/StreamingUsageTest.java create mode 100644 observability/src/test/java/com/ankurm/observability/ToolCallSpansTest.java create mode 100644 observability/src/test/java/com/ankurm/observability/support/Fake.java create mode 100644 observability/src/test/java/com/ankurm/observability/support/Obs.java create mode 100644 observability/src/test/java/com/ankurm/observability/support/TestObs.java create mode 100644 observability/src/test/java/com/ankurm/observability/support/Transcript.java create mode 100644 observability/stack/grafana/provisioning/dashboards/ai.yml create mode 100644 observability/stack/grafana/provisioning/datasources/prom.yml create mode 100644 observability/stack/prometheus.yml diff --git a/README.md b/README.md index 96c952f..78999ce 100644 --- a/README.md +++ b/README.md @@ -16,5 +16,6 @@ Runnable companion code for the Spring AI articles on [ankurm.com](https://ankur | [`advisors/`](advisors) | Three custom advisors -- a logger, a PII redactor (with a stream-safe restore) and a per-request / per-user token budget -- and tests for how the chain is ordered, what `BaseAdvisor` does on a stream, where an advisor sits relative to memory and the tool loop, and what a refusal looks like on a call, a stream and over HTTP (429). A recording stub model, no live model. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Writing Custom Advisors in Spring AI 2.0: Logging, PII Redaction and Token Budgets](https://ankurm.com/spring-ai-2-0-custom-advisors-logging-pii-redaction-token-budgets/) | | [`vector-stores/`](vector-stores) | The same 30,000-document dataset behind `VectorStore` on pgvector, Redis, Qdrant and Elasticsearch: ingest time, recall@10, latency, metadata filtering and running cost, with the defaults that cost recall reproduced (Elasticsearch's quantised mapping, Redis `EF_RUNTIME`, pgvector post-filtering, Qdrant payload indexes). Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Choosing a Vector Store for Spring AI](https://ankurm.com/spring-ai-2-0-vector-store-comparison-pgvector-redis-qdrant-elasticsearch/) | | [`evaluation/`](evaluation) | Testing an LLM app: `RelevancyEvaluator` and `FactCheckingEvaluator` (exactly what they send and which judge replies they accept), a 12-case golden dataset with a pass-rate gate, a deterministic judge for CI, simulated judge noise, a 1-5 graded evaluator and a composite. Stub models only; the one live-judge test is skipped without a key. Spring Boot 4.1.1, Spring AI 2.0.1, JUnit 6, Java 25. | [Testing LLM Apps in Java: Spring AI Evaluators and LLM-as-Judge in JUnit 6](https://ankurm.com/spring-ai-2-0-testing-llm-apps-evaluators-llm-as-judge-junit-6/) | +| [`observability/`](observability) | What Spring AI 2.0.1 records on its own (model, chat client, advisor and tool meters, spans for a tool call), token usage turned into cost per endpoint, a Grafana dashboard checked against live Prometheus and Grafana, and the traps: histograms are opt-in, a response with no usage looks like a free call, prompt text is logged only if switched on. The real `OpenAiChatModel` against a local fake server, so counts are approximate and prices illustrative. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Observability for Spring AI: Tokens, Latency and Cost with Micrometer and OpenTelemetry](https://ankurm.com/spring-ai-2-0-observability-micrometer-opentelemetry-tokens-cost/) | Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/). diff --git a/observability/.gitignore b/observability/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/observability/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/observability/README.md b/observability/README.md new file mode 100644 index 0000000..3d90e1a --- /dev/null +++ b/observability/README.md @@ -0,0 +1,55 @@ +# observability + +Companion code for [Observability for Spring AI: Tokens, Latency and Cost with Micrometer and OpenTelemetry](https://ankurm.com/spring-ai-2-0-observability-micrometer-opentelemetry-tokens-cost/), part of the [Spring AI series](../README.md) on ankurm.com. + +A small Spring AI app (`/ask`, `/summarize`, `/weather` with a tool call, `/stream`), the meters and spans the framework records for it with no code of ours, a 60-line handler that turns token usage into cost per endpoint, and a Grafana dashboard fed by Prometheus. + +**The model class is real, the provider is not.** The app uses the real `OpenAiChatModel` and the real OpenAI Java SDK, pointed at `FakeOpenAiServer`, a local HTTP server that speaks the chat-completions protocol. So every meter name, tag, span and retry count is what Spring AI 2.0.1 really produces. But the token counts are about characters / 4, the latencies are the fake's, and the prices in `application.yml` are illustrative examples, not a current price list. No real provider was measured. The error test shows the SDK's retries against a server that always answers 500. + +## Versions + +| Component | Version | +|---|---| +| Spring Boot | 4.1.1 | +| Spring AI | 2.0.1 | +| Micrometer / micrometer-tracing | 1.17.1 / 1.7.1 | +| OpenTelemetry | 1.62.0 | +| Prometheus / Grafana (release tarballs, no Docker) | 3.15.0 / 13.2.3 | +| Java | 25 (LTS) | + +## Quickstart + +```bash +scripts/run-all.sh # 7 tests, then the stack, a load run and the dashboard check; regenerates output/01 .. 09 +scripts/stack-up.sh # or just the stack: app :8080, Prometheus :9090, Grafana :3000 (admin/admin) +scripts/load.sh 20 # mixed traffic so every panel has data +scripts/stack-down.sh +``` + +Two consecutive runs of `run-all.sh` produce byte-identical `output/` files. The Prometheus and Grafana tarballs must be unpacked under `/tmp/tools/obs/{prom,grafana}` (or set `PROM` and `GRAFANA`). + +## What's here + +| File | What it is | +|---|---| +| [`AiController.java`](src/main/java/com/ankurm/observability/AiController.java), [`WeatherTools.java`](src/main/java/com/ankurm/observability/WeatherTools.java) | The endpoints and the one tool | +| [`EndpointTagConvention.java`](src/main/java/com/ankurm/observability/EndpointTagConvention.java) | Adds a low-cardinality `app.endpoint` tag to the framework's chat client meter | +| [`UsageCostObservationHandler.java`](src/main/java/com/ankurm/observability/UsageCostObservationHandler.java), [`Pricing.java`](src/main/java/com/ankurm/observability/Pricing.java) | Tokens and cost per endpoint, plus a counter for calls that could not be priced | +| [`fake/FakeOpenAiServer.java`](src/main/java/com/ankurm/observability/fake/FakeOpenAiServer.java) | The local stand-in for the provider (keywords `slow`, `boom`, `nousage`, `weather`) | +| [`application.yml`](src/main/resources/application.yml), [`application-stack.yml`](src/main/resources/application-stack.yml) | Settings; the `stack` profile adds latency histograms | +| [`dashboards/spring-ai-observability.json`](dashboards/spring-ai-observability.json) | The Grafana dashboard, generated by [`scripts/make-dashboard.py`](scripts/make-dashboard.py); [`screenshot.png`](dashboards/screenshot.png) is how it rendered | +| [`stack/`](stack) | Prometheus scrape config and Grafana provisioning | + +## Output files + +| File | Written by | +|---|---| +| [`01-builtin-metrics.txt`](output/01-builtin-metrics.txt) | `BuiltInMetricsTest`: the meters the framework creates | +| [`02-tool-call-spans.txt`](output/02-tool-call-spans.txt) | `ToolCallSpansTest`: the span tree of a request with a tool call | +| [`03-cost-per-endpoint.txt`](output/03-cost-per-endpoint.txt) | `CostPerEndpointTest`: tokens and cost per endpoint | +| [`04-streaming-usage.txt`](output/04-streaming-usage.txt) | `StreamingUsageTest`: usage on a streamed response | +| [`05-error-and-retry.txt`](output/05-error-and-retry.txt) | `ErrorAndRetryTest`: a failed call and the SDK's retries | +| [`06-prompt-content.txt`](output/06-prompt-content.txt) | `PromptContentTest`: where prompt text appears, by default and when logging is on | +| [`07-histogram.txt`](output/07-histogram.txt) | `HistogramTest`: latency buckets are opt-in | +| [`08-dashboard-queries.txt`](output/08-dashboard-queries.txt) | `scripts/verify-dashboard.py`: every panel query through Prometheus and Grafana | +| [`09-prometheus-families.txt`](output/09-prometheus-families.txt) | The metric families as Prometheus sees them | diff --git a/observability/dashboards/screenshot.png b/observability/dashboards/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..8770f4418cd56192361e6e93e3e4b89e738e2a46 GIT binary patch literal 142315 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Μ2bPH5M-71bg?kI$mI673IAjwOAO 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 literal 0 HcmV?d00001 diff --git a/observability/dashboards/spring-ai-observability.json b/observability/dashboards/spring-ai-observability.json new file mode 100644 index 0000000..cbe4404 --- /dev/null +++ b/observability/dashboards/spring-ai-observability.json @@ -0,0 +1,308 @@ +{ + "uid": "spring-ai-observability", + "title": "Spring AI: tokens, latency and cost", + "schemaVersion": 39, + "version": 1, + "editable": true, + "refresh": "5s", + "time": { + "from": "now-15m", + "to": "now" + }, + "tags": [ + "spring-ai" + ], + "panels": [ + { + "id": 1, + "title": "Cost per hour by endpoint (USD, illustrative prices)", + "type": "timeseries", + "description": "Rate of app_ai_cost_usd_total scaled to an hourly figure. Prices come from ai.pricing in application.yml.", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD" + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "expr": "sum by (endpoint) (rate(app_ai_cost_usd_total[1m])) * 3600", + "legendFormat": "{{endpoint}}", + "editorMode": "code", + "range": true + } + ] + }, + { + "id": 2, + "title": "Tokens per minute by endpoint and direction", + "type": "timeseries", + "description": "Input and output tokens as the provider reported them.", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "expr": "sum by (endpoint, type) (rate(app_ai_tokens_total[1m])) * 60", + "legendFormat": "{{endpoint}} {{type}}", + "editorMode": "code", + "range": true + } + ] + }, + { + "id": 3, + "title": "Cost per request by endpoint (USD)", + "type": "bargauge", + "description": "Cost divided by successful chat client calls. The two sides use different label names (endpoint vs app_endpoint), so label_replace renames one before the division.", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD" + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "expr": "sum by (endpoint) (increase(app_ai_cost_usd_total[5m])) / on (endpoint) label_replace(sum by (app_endpoint) (increase(spring_ai_chat_client_seconds_count{error=\"none\"}[5m])), \"endpoint\", \"$1\", \"app_endpoint\", \"(.*)\")", + "legendFormat": "{{endpoint}}", + "editorMode": "code", + "range": true + } + ] + }, + { + "id": 4, + "title": "Model call latency p50 / p95 (s)", + "type": "timeseries", + "description": "gen_ai.client.operation, one sample per HTTP call to the provider. Needs the percentiles-histogram property.", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "expr": "histogram_quantile(0.50, sum by (le) (rate(gen_ai_client_operation_seconds_bucket{error=\"none\"}[1m])))", + "legendFormat": "p50", + "editorMode": "code", + "range": true + }, + { + "refId": "B", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "expr": "histogram_quantile(0.95, sum by (le) (rate(gen_ai_client_operation_seconds_bucket{error=\"none\"}[1m])))", + "legendFormat": "p95", + "editorMode": "code", + "range": true + } + ] + }, + { + "id": 5, + "title": "Failed model calls (share)", + "type": "stat", + "description": "Calls whose error tag is not none, over all calls.", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit" + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "expr": "sum(rate(gen_ai_client_operation_seconds_count{error!=\"none\"}[5m])) / sum(rate(gen_ai_client_operation_seconds_count[5m]))", + "legendFormat": "failed", + "editorMode": "code", + "range": true + } + ] + }, + { + "id": 6, + "title": "Tool calls per minute", + "type": "timeseries", + "description": "spring.ai.tool, one series per tool name.", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "expr": "sum by (spring_ai_tool_definition_name) (rate(spring_ai_tool_seconds_count[1m])) * 60", + "legendFormat": "{{spring_ai_tool_definition_name}}", + "editorMode": "code", + "range": true + } + ] + }, + { + "id": 7, + "title": "Mean tool latency (s)", + "type": "timeseries", + "description": "Sum over count of the tool timer.", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "expr": "sum by (spring_ai_tool_definition_name) (rate(spring_ai_tool_seconds_sum[1m])) / sum by (spring_ai_tool_definition_name) (rate(spring_ai_tool_seconds_count[1m]))", + "legendFormat": "{{spring_ai_tool_definition_name}}", + "editorMode": "code", + "range": true + } + ] + }, + { + "id": 8, + "title": "Calls with no cost recorded", + "type": "timeseries", + "description": "Calls that failed, reported no usage, or used a model with no configured price. Zero cost is never recorded silently.", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prom" + }, + "expr": "sum by (endpoint, reason) (increase(app_ai_unpriced_total[5m]))", + "legendFormat": "{{endpoint}} {{reason}}", + "editorMode": "code", + "range": true + } + ] + } + ] +} diff --git a/observability/output/01-builtin-metrics.txt b/observability/output/01-builtin-metrics.txt new file mode 100644 index 0000000..f4d38d6 --- /dev/null +++ b/observability/output/01-builtin-metrics.txt @@ -0,0 +1,16 @@ +# Meters the framework creates for 3 requests (/ask, /weather with one tool call, /stream) + +gen_ai.client.operation TIMER [error, gen_ai.operation.name, gen_ai.request.model, gen_ai.response.model, gen_ai.system] +gen_ai.client.operation.active LONG_TASK_TIMER [gen_ai.operation.name, gen_ai.request.model, gen_ai.response.model, gen_ai.system] +gen_ai.client.token.usage COUNTER [gen_ai.operation.name, gen_ai.request.model, gen_ai.response.model, gen_ai.system, gen_ai.token.type] +spring.ai.advisor TIMER [error, gen_ai.operation.name, gen_ai.system, spring.ai.advisor.name, spring.ai.kind] +spring.ai.advisor.active LONG_TASK_TIMER [gen_ai.operation.name, gen_ai.system, spring.ai.advisor.name, spring.ai.kind] +spring.ai.chat.client TIMER [app.endpoint, error, gen_ai.operation.name, gen_ai.system, spring.ai.chat.client.stream, spring.ai.kind] +spring.ai.chat.client.active LONG_TASK_TIMER [app.endpoint, gen_ai.operation.name, gen_ai.system, spring.ai.chat.client.stream, spring.ai.kind] +spring.ai.tool TIMER [error, gen_ai.operation.name, gen_ai.system, spring.ai.kind, spring.ai.tool.definition.name, spring.ai.tool.type] +spring.ai.tool.active LONG_TASK_TIMER [gen_ai.operation.name, gen_ai.system, spring.ai.kind, spring.ai.tool.definition.name, spring.ai.tool.type] + +model calls recorded (gen_ai.client.operation, error=none): 4 +chat client calls recorded (spring.ai.chat.client): 3 +tool executions recorded (spring.ai.tool): 1 +tokens: input=24 output=29 total=53 diff --git a/observability/output/02-tool-call-spans.txt b/observability/output/02-tool-call-spans.txt new file mode 100644 index 0000000..7db744e --- /dev/null +++ b/observability/output/02-tool-call-spans.txt @@ -0,0 +1,24 @@ +# Span tree for GET /weather?city=Pune (one tool call, two model calls) + +http get /weather [SERVER] + spring_ai chat_client [INTERNAL] + tool _calling [INTERNAL] + call [INTERNAL] + chat gpt-4o-mini [INTERNAL] + POST [CLIENT] + execute_tool getWeather [INTERNAL] + call [INTERNAL] + chat gpt-4o-mini [INTERNAL] + POST [CLIENT] + +attribute keys on the model-call spans: + gen_ai.operation.name + gen_ai.request.model + gen_ai.response.finish_reasons + gen_ai.response.id + gen_ai.response.model + gen_ai.system + gen_ai.usage.input_tokens + gen_ai.usage.output_tokens + gen_ai.usage.total_tokens + spring.ai.model.request.tool.names diff --git a/observability/output/03-cost-per-endpoint.txt b/observability/output/03-cost-per-endpoint.txt new file mode 100644 index 0000000..3a75953 --- /dev/null +++ b/observability/output/03-cost-per-endpoint.txt @@ -0,0 +1,10 @@ +# Tokens and cost per endpoint (illustrative price: 0.15 USD in / 0.60 USD out per million tokens) + +endpoint input output cost USD +ask 1 4 0.00000255 +summarize 511 13 0.00008445 +weather 19 18 0.00001365 + +framework model-level tokens: input=531 output=35 +our per-endpoint tokens : input=531 output=35 +total cost USD: 0.00010065 diff --git a/observability/output/04-streaming-usage.txt b/observability/output/04-streaming-usage.txt new file mode 100644 index 0000000..9b68aaa --- /dev/null +++ b/observability/output/04-streaming-usage.txt @@ -0,0 +1,9 @@ +# Streaming asks for usage; a server that omits it is counted as unpriced + +stream request body asks for usage (stream_options.include_usage): true +request fragment: "stream":true + +two calls to a server that omits the usage block (/ask and /stream): + gen_ai.client.token.usage total, before 7 -> after 7 + app.ai.unpriced{reason=no-usage} for ask : 1 + app.ai.unpriced{reason=no-usage} for stream: 1 diff --git a/observability/output/05-error-and-retry.txt b/observability/output/05-error-and-retry.txt new file mode 100644 index 0000000..367cb75 --- /dev/null +++ b/observability/output/05-error-and-retry.txt @@ -0,0 +1,8 @@ +# One failed user request (the provider returns HTTP 500) + +HTTP requests the provider received for that one call: 4 +model-call timers recorded: + error=InternalServerException response.model=none count=1 +chat-client timers with an error: 1 +app.ai.unpriced{endpoint=ask,reason=no-usage}: 1 +app.ai.cost.usd recorded for the failed call: 0 diff --git a/observability/output/06-prompt-content.txt b/observability/output/06-prompt-content.txt new file mode 100644 index 0000000..7df4a93 --- /dev/null +++ b/observability/output/06-prompt-content.txt @@ -0,0 +1,7 @@ +# Where does the prompt text "ticket-4711-card-ending-0042" appear in telemetry? + +setting log lines with it / span attributes with it / span events with it +defaults 0 / 0 / 0 +spring.ai.chat.observations.log-prompt=true (+ client) 1 / 0 / 0 + +span attributes carrying the text when enabled: [] diff --git a/observability/output/07-histogram.txt b/observability/output/07-histogram.txt new file mode 100644 index 0000000..16246ce --- /dev/null +++ b/observability/output/07-histogram.txt @@ -0,0 +1,7 @@ +# Bucket series for gen_ai_client_operation_seconds after 2 calls + +setting _bucket series / _sum series +defaults 0 / 1 +percentiles-histogram.gen_ai.client.operation=true 69 / 1 + +first bucket series when enabled: gen_ai_client_operation_seconds_bucket{le="0.001"} diff --git a/observability/output/08-dashboard-queries.txt b/observability/output/08-dashboard-queries.txt new file mode 100644 index 0000000..4d11492 --- /dev/null +++ b/observability/output/08-dashboard-queries.txt @@ -0,0 +1,12 @@ +# Every dashboard query, run against Prometheus and through Grafana's query API after scripts/load.sh + +panel prom series grafana frames +Cost per hour by endpoint (USD, illustrative pri [A] 4 4 +Tokens per minute by endpoint and direction [A] 8 8 +Cost per request by endpoint (USD) [A] 4 4 +Model call latency p50 / p95 (s) [A] 1 1 +Model call latency p50 / p95 (s) [B] 1 1 +Failed model calls (share) [A] 1 1 +Tool calls per minute [A] 1 1 +Mean tool latency (s) [A] 1 1 +Calls with no cost recorded [A] 1 1 diff --git a/observability/output/09-prometheus-families.txt b/observability/output/09-prometheus-families.txt new file mode 100644 index 0000000..76734dc --- /dev/null +++ b/observability/output/09-prometheus-families.txt @@ -0,0 +1,26 @@ +# Prometheus metric families this app exposes at /actuator/prometheus (TYPE lines only) + +# TYPE app_ai_cost_usd_total counter +# TYPE app_ai_tokens_total counter +# TYPE app_ai_unpriced_total counter +# TYPE gen_ai_client_operation_active_seconds histogram +# TYPE gen_ai_client_operation_active_seconds_gcount gauge +# TYPE gen_ai_client_operation_active_seconds_gsum gauge +# TYPE gen_ai_client_operation_active_seconds_max gauge +# TYPE gen_ai_client_operation_seconds histogram +# TYPE gen_ai_client_operation_seconds_max gauge +# TYPE gen_ai_client_token_usage_total counter +# TYPE spring_ai_advisor_active_seconds summary +# TYPE spring_ai_advisor_active_seconds_max gauge +# TYPE spring_ai_advisor_seconds summary +# TYPE spring_ai_advisor_seconds_max gauge +# TYPE spring_ai_chat_client_active_seconds histogram +# TYPE spring_ai_chat_client_active_seconds_gcount gauge +# TYPE spring_ai_chat_client_active_seconds_gsum gauge +# TYPE spring_ai_chat_client_active_seconds_max gauge +# TYPE spring_ai_chat_client_seconds histogram +# TYPE spring_ai_chat_client_seconds_max gauge +# TYPE spring_ai_tool_active_seconds summary +# TYPE spring_ai_tool_active_seconds_max gauge +# TYPE spring_ai_tool_seconds summary +# TYPE spring_ai_tool_seconds_max gauge diff --git a/observability/pom.xml b/observability/pom.xml new file mode 100644 index 0000000..33c88fc --- /dev/null +++ b/observability/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + observability + 1.0.0 + observability + Spring AI 2.0 observability: token usage, latency and cost with Micrometer, Prometheus and OpenTelemetry, against the real OpenAI model class and a local fake server. + + + 25 + 2.0.1 + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + org.springframework.boot + spring-boot-starter-opentelemetry + + + org.springframework.ai + spring-ai-starter-model-openai + + + + org.springframework.boot + spring-boot-starter-test + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + -Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8 + + + + + diff --git a/observability/scripts/load.sh b/observability/scripts/load.sh new file mode 100755 index 0000000..c1ae752 --- /dev/null +++ b/observability/scripts/load.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Sends a mixed workload so every dashboard panel has data. Usage: load.sh [rounds] +H=http://127.0.0.1:8080 +for i in $(seq 1 "${1:-10}"); do + curl -s -o /dev/null "$H/ask?q=what+is+observability+$i" + curl -s -o /dev/null "$H/summarize" --get --data-urlencode "text=$(head -c 2000 /dev/zero | tr '\0' 'x') round $i" + curl -s -o /dev/null "$H/weather?city=Pune" + curl -s -o /dev/null "$H/stream?q=tell+me+something+$i" + [ $((i % 4)) -eq 0 ] && curl -s -o /dev/null "$H/ask?q=slow+request+$i" + [ $((i % 5)) -eq 0 ] && curl -s -o /dev/null "$H/ask?q=boom+$i" + [ $((i % 6)) -eq 0 ] && curl -s -o /dev/null "$H/ask?q=nousage+$i" +done +echo "load done" diff --git a/observability/scripts/make-dashboard.py b/observability/scripts/make-dashboard.py new file mode 100755 index 0000000..610775c --- /dev/null +++ b/observability/scripts/make-dashboard.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Generates dashboards/spring-ai-observability.json. Run it, commit the JSON; Grafana provisions the file as-is.""" +import json, pathlib + +DS = {"type": "prometheus", "uid": "prom"} +PANELS = [ + # (title, type, unit, queries[(expr, legend)], description) + ("Cost per hour by endpoint (USD, illustrative prices)", "timeseries", "currencyUSD", + [("sum by (endpoint) (rate(app_ai_cost_usd_total[1m])) * 3600", "{{endpoint}}")], + "Rate of app_ai_cost_usd_total scaled to an hourly figure. Prices come from ai.pricing in application.yml."), + ("Tokens per minute by endpoint and direction", "timeseries", "short", + [("sum by (endpoint, type) (rate(app_ai_tokens_total[1m])) * 60", "{{endpoint}} {{type}}")], + "Input and output tokens as the provider reported them."), + ("Cost per request by endpoint (USD)", "bargauge", "currencyUSD", + [("sum by (endpoint) (increase(app_ai_cost_usd_total[5m])) / on (endpoint) label_replace(sum by (app_endpoint) (increase(spring_ai_chat_client_seconds_count{error=\"none\"}[5m])), \"endpoint\", \"$1\", \"app_endpoint\", \"(.*)\")", "{{endpoint}}")], + "Cost divided by successful chat client calls. The two sides use different label names (endpoint vs app_endpoint), so label_replace renames one before the division."), + ("Model call latency p50 / p95 (s)", "timeseries", "s", + [("histogram_quantile(0.50, sum by (le) (rate(gen_ai_client_operation_seconds_bucket{error=\"none\"}[1m])))", "p50"), + ("histogram_quantile(0.95, sum by (le) (rate(gen_ai_client_operation_seconds_bucket{error=\"none\"}[1m])))", "p95")], + "gen_ai.client.operation, one sample per HTTP call to the provider. Needs the percentiles-histogram property."), + ("Failed model calls (share)", "stat", "percentunit", + [("sum(rate(gen_ai_client_operation_seconds_count{error!=\"none\"}[5m])) / sum(rate(gen_ai_client_operation_seconds_count[5m]))", "failed")], + "Calls whose error tag is not none, over all calls."), + ("Tool calls per minute", "timeseries", "short", + [("sum by (spring_ai_tool_definition_name) (rate(spring_ai_tool_seconds_count[1m])) * 60", "{{spring_ai_tool_definition_name}}")], + "spring.ai.tool, one series per tool name."), + ("Mean tool latency (s)", "timeseries", "s", + [("sum by (spring_ai_tool_definition_name) (rate(spring_ai_tool_seconds_sum[1m])) / sum by (spring_ai_tool_definition_name) (rate(spring_ai_tool_seconds_count[1m]))", "{{spring_ai_tool_definition_name}}")], + "Sum over count of the tool timer."), + ("Calls with no cost recorded", "timeseries", "short", + [("sum by (endpoint, reason) (increase(app_ai_unpriced_total[5m]))", "{{endpoint}} {{reason}}")], + "Calls that failed, reported no usage, or used a model with no configured price. Zero cost is never recorded silently."), +] + +panels = [] +for i, (title, kind, unit, queries, desc) in enumerate(PANELS): + panels.append({ + "id": i + 1, "title": title, "type": kind, "description": desc, "datasource": DS, + "gridPos": {"h": 8, "w": 12, "x": (i % 2) * 12, "y": (i // 2) * 8}, + "fieldConfig": {"defaults": {"unit": unit}, "overrides": []}, + "targets": [{"refId": chr(65 + j), "datasource": DS, "expr": e, "legendFormat": l, "editorMode": "code", "range": True} + for j, (e, l) in enumerate(queries)], + }) + +dash = {"uid": "spring-ai-observability", "title": "Spring AI: tokens, latency and cost", "schemaVersion": 39, "version": 1, + "editable": True, "refresh": "5s", "time": {"from": "now-15m", "to": "now"}, "tags": ["spring-ai"], "panels": panels} +out = pathlib.Path(__file__).resolve().parent.parent / "dashboards" / "spring-ai-observability.json" +out.write_text(json.dumps(dash, indent=2) + "\n") +print("wrote", out, len(panels), "panels") diff --git a/observability/scripts/run-all.sh b/observability/scripts/run-all.sh new file mode 100755 index 0000000..6b8ff8a --- /dev/null +++ b/observability/scripts/run-all.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Regenerates every file in output/: 01-07 from the test suite, 08-09 from the live Prometheus + Grafana stack. +# Needs JDK 25 and Maven on PATH, plus the Prometheus and Grafana tarballs unpacked (see stack-up.sh). +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -q -B test +scripts/stack-up.sh +trap 'scripts/stack-down.sh' EXIT +scripts/load.sh 12 +sleep 8 +{ echo "# Prometheus metric families this app exposes at /actuator/prometheus (TYPE lines only)"; echo + curl -s 127.0.0.1:8080/actuator/prometheus | grep -E '^# TYPE (gen_ai_|spring_ai_|app_ai_)' | sort; } > output/09-prometheus-families.txt +scripts/verify-dashboard.py output/08-dashboard-queries.txt diff --git a/observability/scripts/stack-down.sh b/observability/scripts/stack-down.sh new file mode 100755 index 0000000..e798090 --- /dev/null +++ b/observability/scripts/stack-down.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Stops what stack-up.sh started, by PID (never by pattern). +for f in /tmp/obs-run/*.pid; do [ -f "$f" ] && kill "$(cat "$f")" 2>/dev/null || true; done +echo stopped diff --git a/observability/scripts/stack-up.sh b/observability/scripts/stack-up.sh new file mode 100755 index 0000000..b3007d5 --- /dev/null +++ b/observability/scripts/stack-up.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Starts the whole demo stack without Docker, all on 127.0.0.1: +# fake OpenAI server :8099 -> the Spring app :8080 <-scraped every 2s- Prometheus :9090 <- Grafana :3000 +# PROM and GRAFANA default to where the article says to unpack the release tarballs. +set -euo pipefail +cd "$(dirname "$0")/.." +PROM="${PROM:-/tmp/tools/obs/prom}"; GRAFANA="${GRAFANA:-/tmp/tools/obs/grafana}" +RUN=/tmp/obs-run; rm -rf "$RUN"; mkdir -p "$RUN/dashboards" "$RUN/prom" "$RUN/grafana/data" "$RUN/grafana/logs" "$RUN/grafana/plugins" +cp dashboards/*.json "$RUN/dashboards/" +mvn -q -B -DskipTests package +JAR=$(ls target/*.jar | grep -v original | head -1) +start() { local name=$1; shift; setsid nohup "$@" > "$RUN/$name.log" 2>&1 < /dev/null & echo $! > "$RUN/$name.pid"; } +wait_for() { for i in $(seq 1 60); do curl -s -o /dev/null "$1" && return 0; sleep 1; done; echo "timeout waiting for $1" >&2; exit 1; } + +# the fake server ships inside the same Boot jar; PropertiesLauncher lets us pick its main class +start fake java -Dloader.main=com.ankurm.observability.fake.FakeOpenAiServer -cp "$JAR" org.springframework.boot.loader.launch.PropertiesLauncher 8099 +wait_for http://127.0.0.1:8099/ +start app java -jar "$JAR" --spring.profiles.active=stack +wait_for http://127.0.0.1:8080/actuator/health +start prometheus "$PROM/prometheus" --config.file=stack/prometheus.yml --storage.tsdb.path="$RUN/prom" --web.listen-address=127.0.0.1:9090 +wait_for http://127.0.0.1:9090/-/ready +GF_PATHS_DATA="$RUN/grafana/data" GF_PATHS_LOGS="$RUN/grafana/logs" GF_PATHS_PLUGINS="$RUN/grafana/plugins" \ +GF_PATHS_PROVISIONING="$PWD/stack/grafana/provisioning" GF_SERVER_HTTP_ADDR=127.0.0.1 GF_SERVER_HTTP_PORT=3000 \ +GF_SECURITY_ADMIN_PASSWORD=admin GF_ANALYTICS_REPORTING_ENABLED=false GF_ANALYTICS_CHECK_FOR_UPDATES=false \ + start grafana "$GRAFANA/bin/grafana" server --homepath "$GRAFANA" +wait_for http://127.0.0.1:3000/api/health +echo "up: app :8080, prometheus :9090, grafana :3000 (admin/admin). pids in $RUN/*.pid" diff --git a/observability/scripts/verify-dashboard.py b/observability/scripts/verify-dashboard.py new file mode 100755 index 0000000..9d92430 --- /dev/null +++ b/observability/scripts/verify-dashboard.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Runs every dashboard query twice: straight against Prometheus, and through Grafana's /api/ds/query with the +provisioned datasource (what a panel really does). Prints one line per panel query and exits non-zero if any +query errors or returns no series. Usage: verify-dashboard.py [output-file]""" +import base64, json, pathlib, sys, time, urllib.parse, urllib.request + +root = pathlib.Path(__file__).resolve().parent.parent +dash = json.loads((root / "dashboards" / "spring-ai-observability.json").read_text()) +auth = "Basic " + base64.b64encode(b"admin:admin").decode() + +def get(url, data=None, headers=None): + req = urllib.request.Request(url, data=data, headers=headers or {}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + +lines, bad = [], 0 +now = int(time.time()) +lines.append(f"{'panel':<58} {'prom series':>11} {'grafana frames':>14}") +for p in dash["panels"]: + for t in p["targets"]: + q = urllib.parse.urlencode({"query": t["expr"]}) + prom = get("http://127.0.0.1:9090/api/v1/query?" + q) + n_prom = len(prom["data"]["result"]) if prom["status"] == "success" else -1 + body = json.dumps({"queries": [{"refId": t["refId"], "datasource": t["datasource"], "expr": t["expr"], "instant": False, + "range": True, "interval": "", "intervalMs": 5000, "maxDataPoints": 200}], + "from": str((now - 600) * 1000), "to": str(now * 1000)}).encode() + gf = get("http://127.0.0.1:3000/api/ds/query", body, {"Content-Type": "application/json", "Authorization": auth}) + res = gf["results"][t["refId"]] + n_gf = -1 if "error" in res else len(res.get("frames", [])) + name = f"{p['title'][:48]} [{t['refId']}]" + lines.append(f"{name:<58} {n_prom:>11} {n_gf:>14}") + if n_prom < 1 or n_gf < 1: + bad += 1 +out = "\n".join(lines) +print(out) +if len(sys.argv) > 1: + pathlib.Path(sys.argv[1]).write_text("# Every dashboard query, run against Prometheus and through Grafana's query API after scripts/load.sh\n\n" + out + "\n") +sys.exit(1 if bad else 0) diff --git a/observability/src/main/java/com/ankurm/observability/AiController.java b/observability/src/main/java/com/ankurm/observability/AiController.java new file mode 100644 index 0000000..8fade62 --- /dev/null +++ b/observability/src/main/java/com/ankurm/observability/AiController.java @@ -0,0 +1,47 @@ +package com.ankurm.observability; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; + +/** + * Four endpoints, each tagging its ChatClient call with an {@code endpoint} value in the request + * context. {@link EndpointTagConvention} turns that into a metric tag, which is what lets the + * dashboard show cost per endpoint. + */ +@RestController +public class AiController { + + private final ChatClient chat; + + private final WeatherTools tools; + + public AiController(ChatClient.Builder builder, WeatherTools tools) { + this.chat = builder.build(); + this.tools = tools; + } + + @GetMapping("/ask") + public String ask(@RequestParam String q) { + return chat.prompt().user(q).advisors(a -> a.param(EndpointTagConvention.KEY, "ask")).call().content(); + } + + @GetMapping("/summarize") + public String summarize(@RequestParam String text) { + return chat.prompt().system("Summarize the user's text in one sentence.").user(text) + .advisors(a -> a.param(EndpointTagConvention.KEY, "summarize")).call().content(); + } + + @GetMapping("/weather") + public String weather(@RequestParam String city) { + return chat.prompt().user("What is the weather in " + city + "?").tools(tools) + .advisors(a -> a.param(EndpointTagConvention.KEY, "weather")).call().content(); + } + + @GetMapping("/stream") + public Flux stream(@RequestParam String q) { + return chat.prompt().user(q).advisors(a -> a.param(EndpointTagConvention.KEY, "stream")).stream().content(); + } +} diff --git a/observability/src/main/java/com/ankurm/observability/EndpointTagConvention.java b/observability/src/main/java/com/ankurm/observability/EndpointTagConvention.java new file mode 100644 index 0000000..f106b63 --- /dev/null +++ b/observability/src/main/java/com/ankurm/observability/EndpointTagConvention.java @@ -0,0 +1,22 @@ +package com.ankurm.observability; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import org.springframework.ai.chat.client.observation.ChatClientObservationContext; +import org.springframework.ai.chat.client.observation.DefaultChatClientObservationConvention; + +/** + * Adds an {@code app.endpoint} tag to the ChatClient observation, taken from the request context. + * It is a LOW-cardinality key, so it becomes a metric tag: keep the set of values small and fixed + * (an endpoint name), never a user id or a prompt. + */ +public class EndpointTagConvention extends DefaultChatClientObservationConvention { + + public static final String KEY = "endpoint"; + + @Override + public KeyValues getLowCardinalityKeyValues(ChatClientObservationContext context) { + Object endpoint = context.getRequest().context().get(KEY); + return super.getLowCardinalityKeyValues(context).and(KeyValue.of("app.endpoint", endpoint == null ? "none" : endpoint.toString())); + } +} diff --git a/observability/src/main/java/com/ankurm/observability/ObservabilityApplication.java b/observability/src/main/java/com/ankurm/observability/ObservabilityApplication.java new file mode 100644 index 0000000..d8f881f --- /dev/null +++ b/observability/src/main/java/com/ankurm/observability/ObservabilityApplication.java @@ -0,0 +1,14 @@ +package com.ankurm.observability; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class ObservabilityApplication { + + public static void main(String[] args) { + SpringApplication.run(ObservabilityApplication.class, args); + } +} diff --git a/observability/src/main/java/com/ankurm/observability/ObservabilityConfig.java b/observability/src/main/java/com/ankurm/observability/ObservabilityConfig.java new file mode 100644 index 0000000..0c01eaa --- /dev/null +++ b/observability/src/main/java/com/ankurm/observability/ObservabilityConfig.java @@ -0,0 +1,20 @@ +package com.ankurm.observability; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ObservabilityConfig { + + @Bean + ChatClientObservationConvention endpointTagConvention() { + return new EndpointTagConvention(); + } + + @Bean + UsageCostObservationHandler usageCostObservationHandler(MeterRegistry registry, Pricing pricing) { + return new UsageCostObservationHandler(registry, pricing); + } +} diff --git a/observability/src/main/java/com/ankurm/observability/Pricing.java b/observability/src/main/java/com/ankurm/observability/Pricing.java new file mode 100644 index 0000000..c34f70c --- /dev/null +++ b/observability/src/main/java/com/ankurm/observability/Pricing.java @@ -0,0 +1,25 @@ +package com.ankurm.observability; + +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** USD per one million tokens, by model name prefix. Prices are configuration, not code: they change. */ +@ConfigurationProperties(prefix = "ai.pricing") +public record Pricing(Map models) { + + public record Price(double inputPerMillion, double outputPerMillion) { + } + + /** Longest configured prefix of the model name wins, so "gpt-4o-mini-2024-07-18" matches "gpt-4o-mini" and not "gpt-4o". */ + public Price forModel(String model) { + if (model == null || models == null) { + return null; + } + return models.entrySet().stream() + .filter(e -> model.startsWith(e.getKey())) + .max(java.util.Comparator.comparingInt(e -> e.getKey().length())) + .map(Map.Entry::getValue) + .orElse(null); + } +} diff --git a/observability/src/main/java/com/ankurm/observability/UsageCostObservationHandler.java b/observability/src/main/java/com/ankurm/observability/UsageCostObservationHandler.java new file mode 100644 index 0000000..8ef48c2 --- /dev/null +++ b/observability/src/main/java/com/ankurm/observability/UsageCostObservationHandler.java @@ -0,0 +1,57 @@ +package com.ankurm.observability; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.observation.ChatClientObservationContext; +import org.springframework.ai.chat.metadata.Usage; +import org.springframework.ai.chat.model.ChatResponse; + +/** + * When a ChatClient call finishes, reads the token usage the provider reported and records two + * counters tagged by endpoint and model: {@code app.ai.tokens} and {@code app.ai.cost.usd}. Cost is + * tokens times the configured price. If the provider reported no usage, or the model has no price, + * it counts the call under {@code app.ai.unpriced} instead of silently recording zero. A response + * with no usage block arrives as a usage of 0 tokens, not as null, and a real call always has at + * least one prompt token, so zero is treated as "no usage". + */ +public class UsageCostObservationHandler implements ObservationHandler { + + private final MeterRegistry registry; + + private final Pricing pricing; + + public UsageCostObservationHandler(MeterRegistry registry, Pricing pricing) { + this.registry = registry; + this.pricing = pricing; + } + + @Override + public boolean supportsContext(Observation.Context context) { + return context instanceof ChatClientObservationContext; + } + + @Override + public void onStop(ChatClientObservationContext context) { + String endpoint = String.valueOf(context.getRequest().context().getOrDefault(EndpointTagConvention.KEY, "none")); + ChatClientResponse response = context.getResponse(); + ChatResponse chat = response == null ? null : response.chatResponse(); + Usage usage = chat == null || chat.getMetadata() == null ? null : chat.getMetadata().getUsage(); + String model = chat == null || chat.getMetadata() == null ? null : chat.getMetadata().getModel(); + Pricing.Price price = pricing.forModel(model); + boolean noUsage = usage == null || usage.getPromptTokens() == null || usage.getPromptTokens() == 0; + if (noUsage || price == null) { + Counter.builder("app.ai.unpriced").tag("endpoint", endpoint).tag("reason", noUsage ? "no-usage" : "no-price") + .register(registry).increment(); + return; + } + long in = usage.getPromptTokens(); + long out = usage.getCompletionTokens() == null ? 0 : usage.getCompletionTokens(); + Counter.builder("app.ai.tokens").tag("endpoint", endpoint).tag("model", model).tag("type", "input").register(registry).increment(in); + Counter.builder("app.ai.tokens").tag("endpoint", endpoint).tag("model", model).tag("type", "output").register(registry).increment(out); + Counter.builder("app.ai.cost.usd").tag("endpoint", endpoint).tag("model", model).baseUnit("usd").register(registry) + .increment(in * price.inputPerMillion() / 1_000_000d + out * price.outputPerMillion() / 1_000_000d); + } +} diff --git a/observability/src/main/java/com/ankurm/observability/WeatherTools.java b/observability/src/main/java/com/ankurm/observability/WeatherTools.java new file mode 100644 index 0000000..3cc678b --- /dev/null +++ b/observability/src/main/java/com/ankurm/observability/WeatherTools.java @@ -0,0 +1,14 @@ +package com.ankurm.observability; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.stereotype.Component; + +/** A tool the model can call, so the tool-calling observation has something to measure. */ +@Component +public class WeatherTools { + + @Tool(description = "Current temperature in Celsius for a city") + public String getWeather(String city) { + return city + ": 24 C, clear"; + } +} diff --git a/observability/src/main/java/com/ankurm/observability/fake/FakeOpenAiServer.java b/observability/src/main/java/com/ankurm/observability/fake/FakeOpenAiServer.java new file mode 100644 index 0000000..4ddfefd --- /dev/null +++ b/observability/src/main/java/com/ankurm/observability/fake/FakeOpenAiServer.java @@ -0,0 +1,158 @@ +package com.ankurm.observability.fake; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +/** + * A tiny stand-in for the OpenAI chat completions endpoint, so the REAL {@code OpenAiChatModel} + * (and with it Spring AI's real observation code) runs without an API key. It speaks just enough of + * the protocol: a plain answer, a tool call followed by a final answer, a streamed answer, and an + * HTTP 500. Token counts are a deterministic function of text length (about four characters per + * token) -- they are NOT real tokenizer output, so only their consistency, not their size, means + * anything. Words in the prompt steer it: "slow" adds 250 ms, "boom" returns a 500, "nousage" leaves out the usage block (as some OpenAI-compatible servers do). + */ +public class FakeOpenAiServer implements AutoCloseable { + + private static final JsonMapper JSON = JsonMapper.builder().build(); + + public static final String MODEL = "gpt-4o-mini-2024-07-18"; + + private final HttpServer server; + + private final List requestBodies = new CopyOnWriteArrayList<>(); + + public FakeOpenAiServer(int port) throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + server.createContext("/chat/completions", this::handle); + server.createContext("/v1/chat/completions", this::handle); + server.start(); + } + + public int port() { + return server.getAddress().getPort(); + } + + public String url() { + return "http://127.0.0.1:" + port(); + } + + /** Raw request bodies received, oldest first. */ + public List requestBodies() { + return requestBodies; + } + + @Override + public void close() { + server.stop(0); + } + + private void handle(HttpExchange ex) throws IOException { + String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + requestBodies.add(body); + JsonNode req = JSON.readTree(body); + StringBuilder all = new StringBuilder(); + String lastRole = ""; + String lastUser = ""; + for (JsonNode m : req.path("messages")) { + String content = m.path("content").isString() ? m.path("content").asString() : m.path("content").toString(); + all.append(content); + lastRole = m.path("role").asString(); + if (lastRole.equals("user")) { + lastUser = content; + } + } + String text = all.toString(); + sleep(text.contains("slow") ? 250 : 30); + if (text.contains("boom")) { + send(ex, 500, "{\"error\":{\"message\":\"simulated upstream failure\",\"type\":\"server_error\"}}"); + return; + } + int promptTokens = tokens(text); + boolean noUsage = text.contains("nousage"); + boolean stream = req.path("stream").asBoolean(false); + boolean wantsTool = req.path("tools").size() > 0 && !lastRole.equals("tool") && lastUser.toLowerCase().contains("weather"); + if (wantsTool) { + String city = lastUser.replaceAll("(?s).*weather in ([A-Za-z]+).*", "$1"); + String message = "{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"getWeather\",\"arguments\":\"{\\\"city\\\":\\\"" + city + "\\\"}\"}}]}"; + send(ex, 200, completion(message, "tool_calls", promptTokens, 12, noUsage)); + return; + } + String answer = lastRole.equals("tool") ? "It is 24 C and clear." : "Answer to: " + abbreviate(lastUser); + if (stream) { + streamAnswer(ex, answer, promptTokens, !noUsage && req.path("stream_options").path("include_usage").asBoolean(false)); + return; + } + send(ex, 200, completion("{\"role\":\"assistant\",\"content\":" + JSON.writeValueAsString(answer) + "}", "stop", promptTokens, tokens(answer), noUsage)); + } + + private static String completion(String message, String finish, int in, int out, boolean noUsage) { + return "{\"id\":\"chatcmpl-fake\",\"object\":\"chat.completion\",\"created\":1700000000,\"model\":\"" + MODEL + "\"," + + "\"choices\":[{\"index\":0,\"message\":" + message + ",\"finish_reason\":\"" + finish + "\"}]" + + (noUsage ? "" : ",\"usage\":{\"prompt_tokens\":" + in + ",\"completion_tokens\":" + out + ",\"total_tokens\":" + (in + out) + "}") + "}"; + } + + private void streamAnswer(HttpExchange ex, String answer, int promptTokens, boolean includeUsage) throws IOException { + ex.getResponseHeaders().add("Content-Type", "text/event-stream"); + ex.sendResponseHeaders(200, 0); + List parts = new ArrayList<>(List.of(answer.split("(?<= )"))); + try (OutputStream os = ex.getResponseBody()) { + for (String part : parts) { + os.write(("data: " + chunk("{\"content\":" + JSON.writeValueAsString(part) + "}", "null", false, 0, 0) + "\n\n").getBytes(StandardCharsets.UTF_8)); + } + os.write(("data: " + chunk("{}", "\"stop\"", false, 0, 0) + "\n\n").getBytes(StandardCharsets.UTF_8)); + if (includeUsage) { + os.write(("data: " + chunk("{}", "null", true, promptTokens, tokens(answer)) + "\n\n").getBytes(StandardCharsets.UTF_8)); + } + os.write("data: [DONE]\n\n".getBytes(StandardCharsets.UTF_8)); + } + } + + private static String chunk(String delta, String finish, boolean usage, int in, int out) { + String choices = usage ? "[]" : "[{\"index\":0,\"delta\":" + delta + ",\"finish_reason\":" + finish + "}]"; + String u = usage ? ",\"usage\":{\"prompt_tokens\":" + in + ",\"completion_tokens\":" + out + ",\"total_tokens\":" + (in + out) + "}" : ""; + return "{\"id\":\"chatcmpl-fake\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"" + MODEL + "\",\"choices\":" + choices + u + "}"; + } + + private static void send(HttpExchange ex, int status, String json) throws IOException { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(status, bytes.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(bytes); + } + } + + static int tokens(String s) { + return Math.max(1, (s.length() + 3) / 4); + } + + private static String abbreviate(String s) { + return s.length() > 40 ? s.substring(0, 40) : s; + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public static void main(String[] args) throws Exception { + int port = args.length > 0 ? Integer.parseInt(args[0]) : 8099; + new FakeOpenAiServer(port); + System.out.println("fake OpenAI server on " + port); + Thread.currentThread().join(); + } +} diff --git a/observability/src/main/resources/application-stack.yml b/observability/src/main/resources/application-stack.yml new file mode 100644 index 0000000..843f480 --- /dev/null +++ b/observability/src/main/resources/application-stack.yml @@ -0,0 +1,11 @@ +# Profile used by scripts/stack-up.sh: adds the latency histogram Grafana needs for percentiles. +management: + metrics: + distribution: + percentiles-histogram: + gen_ai.client.operation: true + spring.ai.chat.client: true + minimum-expected-value: + gen_ai.client.operation: 50ms + maximum-expected-value: + gen_ai.client.operation: 30s diff --git a/observability/src/main/resources/application.yml b/observability/src/main/resources/application.yml new file mode 100644 index 0000000..bf919af --- /dev/null +++ b/observability/src/main/resources/application.yml @@ -0,0 +1,31 @@ +spring: + application: + name: spring-ai-observability + ai: + openai: + api-key: test-key-not-a-secret + base-url: ${FAKE_OPENAI_URL:http://localhost:8099} + chat: + options: + model: gpt-4o-mini +management: + endpoints: + web: + exposure: + include: health,prometheus,metrics + tracing: + sampling: + probability: 1.0 + export: + otlp: + enabled: false # set true (and management.opentelemetry.tracing.export.otlp.endpoint) to ship spans to a Collector + otlp: + metrics: + export: + enabled: false # Prometheus scrapes /actuator/prometheus instead +ai: + # Illustrative prices in USD per million tokens. They are example numbers for the demo, not a current price list. + pricing: + models: + gpt-4o-mini: { input-per-million: 0.15, output-per-million: 0.60 } + gpt-4o: { input-per-million: 2.50, output-per-million: 10.00 } diff --git a/observability/src/test/java/com/ankurm/observability/BuiltInMetricsTest.java b/observability/src/test/java/com/ankurm/observability/BuiltInMetricsTest.java new file mode 100644 index 0000000..1f42eb1 --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/BuiltInMetricsTest.java @@ -0,0 +1,63 @@ +package com.ankurm.observability; + +import java.util.Map; + +import com.ankurm.observability.support.Fake; +import com.ankurm.observability.support.Obs; +import com.ankurm.observability.support.Transcript; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What Spring AI 2.0.1 records without any code of ours: the real OpenAiChatModel talks to a local + * fake server, and every meter below was created by the framework. Writes output/01. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BuiltInMetricsTest { + + @DynamicPropertySource + static void fake(DynamicPropertyRegistry r) { + r.add("spring.ai.openai.base-url", Fake.SERVER::url); + } + + @LocalServerPort + int port; + + @Autowired + MeterRegistry registry; + + @Test + void whatTheFrameworkRecords() { + RestClient http = RestClient.create("http://localhost:" + port); + http.get().uri("/ask?q=hello world").retrieve().body(String.class); + http.get().uri("/weather?city=Pune").retrieve().body(String.class); + http.get().uri("/stream?q=tell me").retrieve().body(String.class); + + Map fam = Obs.families(registry, "gen_ai.", "spring.ai."); + double in = Obs.counter(registry, "gen_ai.client.token.usage", "gen_ai.token.type", "input"); + double out = Obs.counter(registry, "gen_ai.client.token.usage", "gen_ai.token.type", "output"); + double total = Obs.counter(registry, "gen_ai.client.token.usage", "gen_ai.token.type", "total"); + + try (Transcript t = new Transcript("01-builtin-metrics.txt", "Meters the framework creates for 3 requests (/ask, /weather with one tool call, /stream)")) { + fam.forEach((name, desc) -> t.line("%-34s %s", name, desc)); + t.blank(); + t.line("model calls recorded (gen_ai.client.operation, error=none): %d", Obs.timerCount(registry, "gen_ai.client.operation", "error", "none")); + t.line("chat client calls recorded (spring.ai.chat.client): %d", Obs.timerCount(registry, "spring.ai.chat.client")); + t.line("tool executions recorded (spring.ai.tool): %d", Obs.timerCount(registry, "spring.ai.tool")); + t.line("tokens: input=%.0f output=%.0f total=%.0f", in, out, total); + } + assertThat(fam).containsKeys("gen_ai.client.operation", "gen_ai.client.token.usage", "spring.ai.chat.client", "spring.ai.tool", "spring.ai.advisor"); + assertThat(Obs.timerCount(registry, "gen_ai.client.operation", "error", "none")).isEqualTo(4); + assertThat(Obs.timerCount(registry, "spring.ai.chat.client")).isEqualTo(3); + assertThat(Obs.timerCount(registry, "spring.ai.tool")).isEqualTo(1); + assertThat(total).isEqualTo(in + out); + } +} diff --git a/observability/src/test/java/com/ankurm/observability/CostPerEndpointTest.java b/observability/src/test/java/com/ankurm/observability/CostPerEndpointTest.java new file mode 100644 index 0000000..967f19d --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/CostPerEndpointTest.java @@ -0,0 +1,67 @@ +package com.ankurm.observability; + +import com.ankurm.observability.support.Fake; +import com.ankurm.observability.support.Obs; +import com.ankurm.observability.support.Transcript; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** Tokens and cost per endpoint from our own handler, checked against what the fake server reported. Writes output/03. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class CostPerEndpointTest { + + @DynamicPropertySource + static void fake(DynamicPropertyRegistry r) { + r.add("spring.ai.openai.base-url", Fake.SERVER::url); + } + + @LocalServerPort + int port; + + @Autowired + MeterRegistry registry; + + @Test + void costIsTokensTimesPriceAndSplitsByEndpoint() { + RestClient http = RestClient.create("http://localhost:" + port); + String shortText = "tiny"; + String longText = "x".repeat(2000); + http.get().uri("/ask?q={q}", shortText).retrieve().body(String.class); + http.get().uri("/summarize?text={t}", longText).retrieve().body(String.class); + http.get().uri("/weather?city=Pune").retrieve().body(String.class); + + try (Transcript t = new Transcript("03-cost-per-endpoint.txt", "Tokens and cost per endpoint (illustrative price: 0.15 USD in / 0.60 USD out per million tokens)")) { + t.line("%-10s %8s %8s %14s", "endpoint", "input", "output", "cost USD"); + double sum = 0; + for (String ep : new String[] { "ask", "summarize", "weather" }) { + double in = Obs.counter(registry, "app.ai.tokens", "endpoint", ep, "type", "input"); + double out = Obs.counter(registry, "app.ai.tokens", "endpoint", ep, "type", "output"); + double cost = Obs.counter(registry, "app.ai.cost.usd", "endpoint", ep); + sum += cost; + t.line("%-10s %8.0f %8.0f %14.8f", ep, in, out, cost); + assertThat(cost).isCloseTo(in * 0.15 / 1e6 + out * 0.60 / 1e6, within(1e-12)); + } + double modelIn = Obs.counter(registry, "gen_ai.client.token.usage", "gen_ai.token.type", "input"); + double modelOut = Obs.counter(registry, "gen_ai.client.token.usage", "gen_ai.token.type", "output"); + double ourIn = Obs.counter(registry, "app.ai.tokens", "type", "input"); + double ourOut = Obs.counter(registry, "app.ai.tokens", "type", "output"); + t.blank(); + t.line("framework model-level tokens: input=%.0f output=%.0f", modelIn, modelOut); + t.line("our per-endpoint tokens : input=%.0f output=%.0f", ourIn, ourOut); + t.line("total cost USD: %.8f", sum); + assertThat(ourIn).isEqualTo(modelIn); + assertThat(ourOut).isEqualTo(modelOut); + } + // The summarize endpoint sent ~2000 characters and must cost more than the one-word ask. + assertThat(Obs.counter(registry, "app.ai.cost.usd", "endpoint", "summarize")).isGreaterThan(Obs.counter(registry, "app.ai.cost.usd", "endpoint", "ask")); + } +} diff --git a/observability/src/test/java/com/ankurm/observability/ErrorAndRetryTest.java b/observability/src/test/java/com/ankurm/observability/ErrorAndRetryTest.java new file mode 100644 index 0000000..a308d72 --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/ErrorAndRetryTest.java @@ -0,0 +1,54 @@ +package com.ankurm.observability; + +import com.ankurm.observability.support.Fake; +import com.ankurm.observability.support.Obs; +import com.ankurm.observability.support.Transcript; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** A failing provider: how many HTTP requests one call makes, what the error tag looks like, and what our cost handler does. Writes output/05. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ErrorAndRetryTest { + + @DynamicPropertySource + static void fake(DynamicPropertyRegistry r) { + r.add("spring.ai.openai.base-url", Fake.SERVER::url); + } + + @LocalServerPort + int port; + + @Autowired + MeterRegistry registry; + + @Test + void oneFailedCallIsSeveralHttpRequests() { + RestClient http = RestClient.create("http://localhost:" + port); + int before = Fake.SERVER.requestBodies().size(); + assertThatThrownBy(() -> http.get().uri("/ask?q=boom").retrieve().body(String.class)).isInstanceOf(HttpServerErrorException.class); + int attempts = Fake.SERVER.requestBodies().size() - before; + + try (Transcript t = new Transcript("05-error-and-retry.txt", "One failed user request (the provider returns HTTP 500)")) { + t.line("HTTP requests the provider received for that one call: %d", attempts); + t.line("model-call timers recorded:"); + registry.find("gen_ai.client.operation").timers().stream() + .sorted(java.util.Comparator.comparing(x -> x.getId().getTag("error"))) + .forEach(x -> t.line(" error=%-24s response.model=%-24s count=%d", x.getId().getTag("error"), x.getId().getTag("gen_ai.response.model"), x.count())); + t.line("chat-client timers with an error: %d", registry.find("spring.ai.chat.client").tag("error", "InternalServerException").timers().size()); + t.line("app.ai.unpriced{endpoint=ask,reason=no-usage}: %.0f", Obs.counter(registry, "app.ai.unpriced", "endpoint", "ask", "reason", "no-usage")); + t.line("app.ai.cost.usd recorded for the failed call: %.0f", Obs.counter(registry, "app.ai.cost.usd", "endpoint", "ask")); + } + assertThat(attempts).isGreaterThan(1); + assertThat(Obs.timerCount(registry, "gen_ai.client.operation", "error", "InternalServerException")).isEqualTo(1); + } +} diff --git a/observability/src/test/java/com/ankurm/observability/HistogramTest.java b/observability/src/test/java/com/ankurm/observability/HistogramTest.java new file mode 100644 index 0000000..f260a4d --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/HistogramTest.java @@ -0,0 +1,61 @@ +package com.ankurm.observability; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +import com.ankurm.observability.support.Fake; +import com.ankurm.observability.support.Transcript; +import org.junit.jupiter.api.Test; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.server.context.WebServerApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Timers carry no histogram buckets unless asked, so histogram_quantile() has nothing to work with. + * Starts the app twice, scrapes /actuator/prometheus and counts the bucket series. Writes output/07. + */ +class HistogramTest { + + private static final String METER = "gen_ai_client_operation_seconds"; + + private record Scrape(long buckets, long sumLines, String firstBucket) { + } + + private static Scrape run(String... props) { + String[] args = Stream.concat(Stream.of("spring.ai.openai.base-url=" + Fake.SERVER.url(), "server.port=0"), Arrays.stream(props)) + .map(a -> "--" + a).toArray(String[]::new); + try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ObservabilityApplication.class) + .web(WebApplicationType.SERVLET).run(args)) { + int port = ((WebServerApplicationContext) ctx).getWebServer().getPort(); + RestClient http = RestClient.create("http://localhost:" + port); + http.get().uri("/ask?q=hello").retrieve().body(String.class); + http.get().uri("/ask?q=slow one").retrieve().body(String.class); + String body = http.get().uri("/actuator/prometheus").retrieve().body(String.class); + List lines = body.lines().filter(l -> l.startsWith(METER)).toList(); + List buckets = lines.stream().filter(l -> l.startsWith(METER + "_bucket")).toList(); + return new Scrape(buckets.size(), lines.stream().filter(l -> l.startsWith(METER + "_sum")).count(), + buckets.isEmpty() ? "-" : buckets.get(0).replaceAll("\\{.*le=\"([^\"]+)\".*}", "{le=\"$1\"}").replaceAll(" .*", "")); + } + } + + @Test + void bucketsAreOptIn() { + Scrape off = run(); + Scrape on = run("management.metrics.distribution.percentiles-histogram.gen_ai.client.operation=true"); + try (Transcript t = new Transcript("07-histogram.txt", "Bucket series for " + METER + " after 2 calls")) { + t.line("%-70s %s", "setting", "_bucket series / _sum series"); + t.line("%-70s %d / %d", "defaults", off.buckets(), off.sumLines()); + t.line("%-70s %d / %d", "percentiles-histogram.gen_ai.client.operation=true", on.buckets(), on.sumLines()); + t.blank(); + t.line("first bucket series when enabled: %s", on.firstBucket()); + } + assertThat(off.buckets()).isZero(); + assertThat(off.sumLines()).isPositive(); + assertThat(on.buckets()).isGreaterThan(10); + } +} diff --git a/observability/src/test/java/com/ankurm/observability/PromptContentTest.java b/observability/src/test/java/com/ankurm/observability/PromptContentTest.java new file mode 100644 index 0000000..9b5fe12 --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/PromptContentTest.java @@ -0,0 +1,80 @@ +package com.ankurm.observability; + +import java.util.ArrayList; +import java.util.List; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.ankurm.observability.support.Fake; +import com.ankurm.observability.support.TestObs; +import com.ankurm.observability.support.Transcript; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ConfigurableApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Is the prompt text in your telemetry? By default no; two properties put it there. Writes output/06. */ +class PromptContentTest { + + private static final String SECRET = "ticket-4711-card-ending-0042"; + + private record Result(int logLines, List spanAttributesWithSecret, int spanEventsWithSecret) { + } + + private static Result run(String... props) { + ListAppender appender = new ListAppender<>(); + appender.start(); + Logger root = null; + List all = new ArrayList<>(List.of("spring.ai.openai.base-url=" + Fake.SERVER.url(), "server.port=0")); + all.addAll(List.of(props)); + try (ConfigurableApplicationContext ctx = new SpringApplicationBuilder(ObservabilityApplication.class, TestObs.class) + .web(WebApplicationType.SERVLET).run(all.stream().map(a -> "--" + a).toArray(String[]::new))) { + // Spring Boot re-initialises logging while the context starts and would drop an appender attached earlier. + root = (Logger) LoggerFactory.getLogger("org.springframework.ai"); + root.addAppender(appender); + ctx.getBean(AiController.class).ask(SECRET); + InMemorySpanExporter exporter = ctx.getBean(InMemorySpanExporter.class); + List attrs = new ArrayList<>(); + int events = 0; + for (SpanData s : exporter.getFinishedSpanItems()) { + s.getAttributes().forEach((k, v) -> { + if (String.valueOf(v).contains(SECRET)) { + attrs.add(s.getName() + " -> " + k.getKey()); + } + }); + events += (int) s.getEvents().stream().filter(e -> e.getAttributes().asMap().values().stream().anyMatch(v -> String.valueOf(v).contains(SECRET))).count(); + } + long logs = appender.list.stream().filter(e -> e.getFormattedMessage().contains(SECRET)).count(); + return new Result((int) logs, attrs.stream().sorted().toList(), events); + } + finally { + if (root != null) { + root.detachAppender(appender); + } + } + } + + @Test + void promptTextIsOffByDefault() { + Result off = run(); + Result on = run("spring.ai.chat.observations.log-prompt=true", "spring.ai.chat.client.observations.log-prompt=true"); + + try (Transcript t = new Transcript("06-prompt-content.txt", "Where does the prompt text \"" + SECRET + "\" appear in telemetry?")) { + t.line("%-62s %s", "setting", "log lines with it / span attributes with it / span events with it"); + t.line("%-62s %d / %d / %d", "defaults", off.logLines(), off.spanAttributesWithSecret().size(), off.spanEventsWithSecret()); + t.line("%-62s %d / %d / %d", "spring.ai.chat.observations.log-prompt=true (+ client)", on.logLines(), on.spanAttributesWithSecret().size(), on.spanEventsWithSecret()); + t.line(""); + t.line("span attributes carrying the text when enabled: %s", on.spanAttributesWithSecret()); + } + assertThat(off.logLines()).isZero(); + assertThat(off.spanAttributesWithSecret()).isEmpty(); + assertThat(off.spanEventsWithSecret()).isZero(); + assertThat(on.logLines() + on.spanAttributesWithSecret().size() + on.spanEventsWithSecret()).isPositive(); + } +} diff --git a/observability/src/test/java/com/ankurm/observability/StreamingUsageTest.java b/observability/src/test/java/com/ankurm/observability/StreamingUsageTest.java new file mode 100644 index 0000000..93c2d8d --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/StreamingUsageTest.java @@ -0,0 +1,59 @@ +package com.ankurm.observability; + +import com.ankurm.observability.support.Fake; +import com.ankurm.observability.support.Obs; +import com.ankurm.observability.support.Transcript; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Streaming responses and missing usage: what the request asks for, and what happens when a server leaves usage out. Writes output/04. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class StreamingUsageTest { + + @DynamicPropertySource + static void fake(DynamicPropertyRegistry r) { + r.add("spring.ai.openai.base-url", Fake.SERVER::url); + } + + @LocalServerPort + int port; + + @Autowired + MeterRegistry registry; + + @Test + void streamingRequestsUsageAndAMissingUsageBlockIsCountedNotIgnored() { + RestClient http = RestClient.create("http://localhost:" + port); + int before = Fake.SERVER.requestBodies().size(); + http.get().uri("/stream?q=tell me").retrieve().body(String.class); + String streamRequest = Fake.SERVER.requestBodies().get(before); + + double tokensBefore = Obs.counter(registry, "gen_ai.client.token.usage", "gen_ai.token.type", "total"); + http.get().uri("/ask?q=nousage please").retrieve().body(String.class); + http.get().uri("/stream?q=nousage please").retrieve().body(String.class); + double tokensAfter = Obs.counter(registry, "gen_ai.client.token.usage", "gen_ai.token.type", "total"); + + boolean asksForUsage = streamRequest.replace(" ", "").contains("\"stream_options\":{\"include_usage\":true"); + try (Transcript t = new Transcript("04-streaming-usage.txt", "Streaming asks for usage; a server that omits it is counted as unpriced")) { + t.line("stream request body asks for usage (stream_options.include_usage): %s", asksForUsage); + t.line("request fragment: %s", streamRequest.replaceAll(".*(\"stream\":true).*", "$1")); + t.line(""); + t.line("two calls to a server that omits the usage block (/ask and /stream):"); + t.line(" gen_ai.client.token.usage total, before %.0f -> after %.0f", tokensBefore, tokensAfter); + t.line(" app.ai.unpriced{reason=no-usage} for ask : %.0f", Obs.counter(registry, "app.ai.unpriced", "endpoint", "ask", "reason", "no-usage")); + t.line(" app.ai.unpriced{reason=no-usage} for stream: %.0f", Obs.counter(registry, "app.ai.unpriced", "endpoint", "stream", "reason", "no-usage")); + } + assertThat(asksForUsage).isTrue(); + assertThat(tokensAfter).isEqualTo(tokensBefore); + assertThat(Obs.counter(registry, "app.ai.unpriced", "endpoint", "ask", "reason", "no-usage")).isEqualTo(1); + assertThat(Obs.counter(registry, "app.ai.unpriced", "endpoint", "stream", "reason", "no-usage")).isEqualTo(1); + } +} diff --git a/observability/src/test/java/com/ankurm/observability/ToolCallSpansTest.java b/observability/src/test/java/com/ankurm/observability/ToolCallSpansTest.java new file mode 100644 index 0000000..4aa302d --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/ToolCallSpansTest.java @@ -0,0 +1,74 @@ +package com.ankurm.observability; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.ankurm.observability.support.Fake; +import com.ankurm.observability.support.TestObs; +import com.ankurm.observability.support.Transcript; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** The trace of one tool-calling request, read from an in-memory OpenTelemetry exporter. Writes output/02. */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(TestObs.class) +class ToolCallSpansTest { + + @DynamicPropertySource + static void fake(DynamicPropertyRegistry r) { + r.add("spring.ai.openai.base-url", Fake.SERVER::url); + } + + @LocalServerPort + int port; + + @Autowired + InMemorySpanExporter exporter; + + @Test + void oneToolCallingRequestIsOneTree() { + exporter.reset(); + RestClient.create("http://localhost:" + port).get().uri("/weather?city=Pune").retrieve().body(String.class); + + // The server span ends after the response has been written, so the client can get its answer first. + org.awaitility.Awaitility.await().atMost(java.time.Duration.ofSeconds(5)).until( + () -> exporter.getFinishedSpanItems().stream().anyMatch(s -> s.getName().equals("http get /weather"))); + List spans = new ArrayList<>(exporter.getFinishedSpanItems()); + // Drop the spans that are not part of this request's tree (there are none expected, but be explicit). + spans.sort(Comparator.comparingLong(SpanData::getStartEpochNanos)); + Map byId = spans.stream().collect(Collectors.toMap(s -> s.getSpanId(), s -> s)); + + try (Transcript t = new Transcript("02-tool-call-spans.txt", "Span tree for GET /weather?city=Pune (one tool call, two model calls)")) { + for (SpanData s : spans) { + int depth = 0; + SpanData p = byId.get(s.getParentSpanId()); + while (p != null) { + depth++; + p = byId.get(p.getParentSpanId()); + } + t.line("%s%s [%s]", " ".repeat(depth), s.getName(), s.getKind()); + } + t.blank(); + t.line("attribute keys on the model-call spans:"); + spans.stream().filter(s -> s.getName().startsWith("chat")).findFirst().ifPresent(s -> + s.getAttributes().asMap().keySet().stream().map(AttributeKey::getKey).sorted().forEach(k -> t.line(" %s", k))); + } + assertThat(spans).extracting(SpanData::getName).contains("spring_ai chat_client", "execute_tool getWeather"); + assertThat(spans.stream().filter(s -> s.getName().startsWith("chat ")).count()).isEqualTo(2); + assertThat(spans.stream().map(SpanData::getTraceId).distinct().count()).isEqualTo(1); + } +} diff --git a/observability/src/test/java/com/ankurm/observability/support/Fake.java b/observability/src/test/java/com/ankurm/observability/support/Fake.java new file mode 100644 index 0000000..7acca4f --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/support/Fake.java @@ -0,0 +1,23 @@ +package com.ankurm.observability.support; + +import java.io.IOException; + +import com.ankurm.observability.fake.FakeOpenAiServer; + +/** One fake OpenAI server for the whole test JVM, so every Spring context can point at it. */ +public final class Fake { + + public static final FakeOpenAiServer SERVER = start(); + + private Fake() { + } + + private static FakeOpenAiServer start() { + try { + return new FakeOpenAiServer(0); + } + catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/observability/src/test/java/com/ankurm/observability/support/Obs.java b/observability/src/test/java/com/ankurm/observability/support/Obs.java new file mode 100644 index 0000000..acd1d9c --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/support/Obs.java @@ -0,0 +1,47 @@ +package com.ankurm.observability.support; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; + +/** Small helpers for reading a MeterRegistry in tests. */ +public final class Obs { + + private Obs() { + } + + /** Sum of the counters with this name whose tags include all the given key/value pairs. */ + public static double counter(MeterRegistry registry, String name, String... tagPairs) { + return registry.find(name).tags(tagPairs).counters().stream().mapToDouble(c -> c.count()).sum(); + } + + /** Number of recordings of the timers with this name whose tags include all the given pairs. */ + public static long timerCount(MeterRegistry registry, String name, String... tagPairs) { + return registry.find(name).tags(tagPairs).timers().stream().mapToLong(t -> t.count()).sum(); + } + + /** One line per meter name starting with a prefix: name, type, and the sorted tag keys. */ + public static Map families(MeterRegistry registry, String... prefixes) { + Map out = new TreeMap<>(); + for (Meter m : registry.getMeters()) { + String name = m.getId().getName(); + for (String p : prefixes) { + if (name.startsWith(p)) { + String keys = m.getId().getTags().stream().map(Tag::getKey).sorted().collect(Collectors.joining(", ")); + out.merge(name, m.getId().getType() + " [" + keys + "]", (a, b) -> a.length() >= b.length() ? a : b); + } + } + } + return out; + } + + public static List names(MeterRegistry registry, String prefix) { + return registry.getMeters().stream().map(m -> m.getId().getName()).filter(n -> n.startsWith(prefix)).distinct().sorted(Comparator.naturalOrder()).toList(); + } +} diff --git a/observability/src/test/java/com/ankurm/observability/support/TestObs.java b/observability/src/test/java/com/ankurm/observability/support/TestObs.java new file mode 100644 index 0000000..7e9385e --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/support/TestObs.java @@ -0,0 +1,22 @@ +package com.ankurm.observability.support; + +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SpanProcessor; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +/** Captures finished spans in memory (synchronously) so a test can read the span tree. */ +@TestConfiguration +public class TestObs { + + @Bean + InMemorySpanExporter inMemorySpanExporter() { + return InMemorySpanExporter.create(); + } + + @Bean + SpanProcessor simpleSpanProcessor(InMemorySpanExporter exporter) { + return SimpleSpanProcessor.create(exporter); + } +} diff --git a/observability/src/test/java/com/ankurm/observability/support/Transcript.java b/observability/src/test/java/com/ankurm/observability/support/Transcript.java new file mode 100644 index 0000000..c2350cd --- /dev/null +++ b/observability/src/test/java/com/ankurm/observability/support/Transcript.java @@ -0,0 +1,47 @@ +package com.ankurm.observability.support; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Writes a numbered transcript under {@code output/} (repository root, not {@code docs/}) and + * echoes it to the console. Every console block quoted in the article comes out of one of these + * files verbatim. + */ +public final class Transcript implements AutoCloseable { + + private final Path path; + private final StringWriter buffer = new StringWriter(); + private final PrintWriter out = new PrintWriter(buffer); + + public Transcript(String fileName, String title) { + this.path = Path.of("output", fileName); + out.println("# " + title); + out.println(); + } + + public Transcript line(String format, Object... args) { + out.println(args.length == 0 ? format : String.format(format, args)); + return this; + } + + public Transcript blank() { + out.println(); + return this; + } + + @Override + public void close() { + out.flush(); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, buffer.toString()); + } catch (IOException e) { + throw new IllegalStateException("could not write " + path, e); + } + System.out.print(buffer); + } +} diff --git a/observability/stack/grafana/provisioning/dashboards/ai.yml b/observability/stack/grafana/provisioning/dashboards/ai.yml new file mode 100644 index 0000000..ac62c52 --- /dev/null +++ b/observability/stack/grafana/provisioning/dashboards/ai.yml @@ -0,0 +1,6 @@ +apiVersion: 1 +providers: + - name: spring-ai + type: file + options: + path: /tmp/obs-run/dashboards diff --git a/observability/stack/grafana/provisioning/datasources/prom.yml b/observability/stack/grafana/provisioning/datasources/prom.yml new file mode 100644 index 0000000..c0404d8 --- /dev/null +++ b/observability/stack/grafana/provisioning/datasources/prom.yml @@ -0,0 +1,8 @@ +apiVersion: 1 +datasources: + - name: Prometheus + uid: prom + type: prometheus + access: proxy + url: http://127.0.0.1:9090 + isDefault: true diff --git a/observability/stack/prometheus.yml b/observability/stack/prometheus.yml new file mode 100644 index 0000000..28c94d4 --- /dev/null +++ b/observability/stack/prometheus.yml @@ -0,0 +1,7 @@ +global: + scrape_interval: 2s +scrape_configs: + - job_name: spring-ai-observability + metrics_path: /actuator/prometheus + static_configs: + - targets: ["127.0.0.1:8080"]