Skip to content

Commit 8057f89

Browse files
committed
feat(telemetry): add unified OTLP telemetry
Introduce a unified, lightweight, zero-dependency telemetry layer to replace the legacy openinference namespace. Group OTLP transport options under a nested otlp object, and replace the format string with a semantics list (allowing multiple active tracing conventions). This implementation: 1. Allows users to specify semantics: [openinference, otel_genai]. 2. Collects and compiles trace attributes for OpenInference and OpenTelemetry GenAI in a single pass when both are active. 3. Integrates queue capacity, retry/backoff, and batch exporting. 4. Refactors code structure, C++ config unit tests, python integration tests, CLI commands, and markdown documentation.
1 parent 1080c65 commit 8057f89

26 files changed

Lines changed: 3835 additions & 86 deletions

CMakeLists.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ set(SOURCES_CORE
596596
src/cpp/server/system_info.cpp
597597
src/cpp/server/recipe_options.cpp
598598
src/cpp/server/runtime_config.cpp
599+
src/cpp/server/telemetry.cpp
599600
src/cpp/server/logging_config.cpp
600601
src/cpp/server/log_stream.cpp
601602
src/cpp/server/prometheus_metrics.cpp
@@ -1740,3 +1741,17 @@ if(EXISTS "${_LATEST_FALLBACK_TEST_SRC}")
17401741
include(CTest)
17411742
add_test(NAME LatestVersionFallbackTest COMMAND test_latest_version_fallback)
17421743
endif()
1744+
1745+
set(_TELEMETRY_HELPERS_TEST_SRC "${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_telemetry_helpers.cpp")
1746+
if(EXISTS "${_TELEMETRY_HELPERS_TEST_SRC}")
1747+
add_executable(test_telemetry_helpers test/cpp/test_telemetry_helpers.cpp)
1748+
target_link_libraries(test_telemetry_helpers PRIVATE lemonade-server-core)
1749+
add_test(NAME TelemetryHelpersTest COMMAND test_telemetry_helpers)
1750+
endif()
1751+
1752+
set(_CONFIG_MIGRATION_TEST_SRC "${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_config_migration.cpp")
1753+
if(EXISTS "${_CONFIG_MIGRATION_TEST_SRC}")
1754+
add_executable(test_config_migration test/cpp/test_config_migration.cpp)
1755+
target_link_libraries(test_config_migration PRIVATE lemonade-server-core)
1756+
add_test(NAME ConfigMigrationTest COMMAND test_config_migration)
1757+
endif()

docs/api/lemonade.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ We have designed a set of Lemonade-specific endpoints to enable client applicati
2626
| `WS` | [`/logs/stream`](#log-streaming-api-websocket) | Log Streaming |
2727
| `GET` | [`/live`](#get-live) | Check server liveness for load balancers and orchestrators |
2828
| `GET` | [`/metrics`](#get-metrics) | Prometheus metrics scrape endpoint |
29+
| `POST` | [`/internal/telemetry`](#post-internaltelemetry) | Dynamically toggle telemetry tracing |
30+
| `POST` | [`/internal/telemetry/flush`](#post-internaltelemetryflush) | Force-flush all queued telemetry trace spans |
2931

3032
## `POST /v1/pull`
3133
<sub>![Status](https://img.shields.io/badge/status-fully_available-green)</sub>
@@ -1333,3 +1335,66 @@ curl http://localhost:13305/live
13331335
```json
13341336
{"status":"ok"}
13351337
```
1338+
1339+
## Internal Endpoints
1340+
1341+
Internal endpoints are used for server control and configuration. By default, they are secured by `LEMONADE_ADMIN_API_KEY` (if set) to separate control privileges from standard inference operations.
1342+
1343+
## `POST /internal/telemetry`
1344+
<sub>![Status](https://img.shields.io/badge/status-fully_available-green)</sub>
1345+
1346+
Dynamically toggles telemetry tracing on the server and logs the change. This change is in-memory only and is not persisted to the `config.json` file (reverts on server restart).
1347+
1348+
#### Parameters
1349+
1350+
The request body must be a JSON object with the following fields:
1351+
1352+
| Parameter | Type | Required | Description |
1353+
|-----------|------|----------|-------------|
1354+
| `enabled` | boolean | Yes | Whether to enable (`true`) or disable (`false`) telemetry tracing. |
1355+
1356+
Example request:
1357+
1358+
```bash
1359+
curl -X POST http://localhost:13305/internal/telemetry \
1360+
-H "Content-Type: application/json" \
1361+
-d '{
1362+
"enabled": false
1363+
}'
1364+
```
1365+
1366+
#### Response Format
1367+
1368+
Returns a JSON object indicating success and the new state:
1369+
1370+
```json
1371+
{
1372+
"status": "success",
1373+
"enabled": false
1374+
}
1375+
```
1376+
1377+
## `POST /internal/telemetry/flush`
1378+
<sub>![Status](https://img.shields.io/badge/status-fully_available-green)</sub>
1379+
1380+
Forces the in-memory telemetry queue to flush all buffered trace spans immediately to the configured OTLP collector. This call blocks until all currently queued spans are serialized and sent.
1381+
1382+
#### Parameters
1383+
1384+
None.
1385+
1386+
Example request:
1387+
1388+
```bash
1389+
curl -X POST http://localhost:13305/internal/telemetry/flush
1390+
```
1391+
1392+
#### Response Format
1393+
1394+
Returns a JSON object indicating successful completion of the flush operation:
1395+
1396+
```json
1397+
{
1398+
"status": "flushed"
1399+
}
1400+
```

docs/guide/cli.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The `lemonade` CLI is the primary tool for interacting with Lemonade Server from
1616
- [Options for launch](#options-for-launch)
1717
- [Options for bench](#options-for-bench)
1818
- [Options for scan](#options-for-scan)
19+
- [Options for telemetry](#options-for-telemetry)
1920

2021
## Commands
2122

@@ -38,6 +39,7 @@ The `lemonade` CLI is the primary tool for interacting with Lemonade Server from
3839
| `backends` | List supported recipes and backends or list all available recipes and backends with `--all`. Use `install` or `uninstall` to manage backends. |
3940
| `cloud` | Manage cloud OpenAI-compatible providers. See command options [below](#options-for-cloud). |
4041
| `scan` | Scan for network beacons on the local network. See command options [below](#options-for-scan). |
42+
| `telemetry` | Dynamically enable or disable telemetry tracing. See command options [below](#options-for-telemetry). |
4143

4244
### Model Management
4345

@@ -659,6 +661,29 @@ lemonade scan
659661
lemonade scan --duration 5
660662
```
661663

664+
## Options for telemetry
665+
666+
Dynamically toggle telemetry tracing on the server. This setting is applied immediately in-memory without requiring a server restart, but is not persisted to the server's `config.json` (meaning it will revert when the server restarts).
667+
668+
```bash
669+
lemonade telemetry <on|off>
670+
```
671+
672+
| Argument | Description |
673+
|----------|-------------|
674+
| `on` | Enable telemetry tracing. |
675+
| `off` | Disable telemetry tracing. |
676+
677+
**Examples:**
678+
679+
```bash
680+
# Enable telemetry tracing dynamically
681+
lemonade telemetry on
682+
683+
# Disable telemetry tracing dynamically
684+
lemonade telemetry off
685+
```
686+
662687
## Options for bench
663688

664689
The `bench` command measures chat completion performance (TTFT and tokens-per-second) for one or more models across one or more installed backends, context sizes, and scenario workloads. It sends `POST /api/v1/chat/completions` requests and extracts timing data from the server response.

docs/guide/configuration/README.md

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Values set in the user's `config.json` always take precedence over these seeded
3333

3434
```json
3535
{
36-
"config_version": 1,
36+
"config_version": 2,
3737
"port": 13305,
3838
"host": "localhost",
3939
"log_level": "info",
@@ -83,13 +83,30 @@ Values set in the user's `config.json` always take precedence over these seeded
8383
"vulkan_bin": "builtin"
8484
},
8585
"flm": {
86-
"args": "",
86+
"args": ""
8787
},
8888
"ryzenai": {
8989
"server_bin": "builtin"
9090
},
9191
"kokoro": {
9292
"cpu_bin": "builtin"
93+
},
94+
"telemetry": {
95+
"enabled": false,
96+
"hide_inputs": false,
97+
"hide_outputs": false,
98+
"hide_thinking": false,
99+
"max_queue_capacity": 1000,
100+
"otlp": {
101+
"endpoint": "http://localhost:4317",
102+
"protocol": "http/protobuf",
103+
"semantics": ["openinference", "otel_genai"],
104+
"headers": {},
105+
"max_retries": 0,
106+
"retry_backoff_base_s": 5.0,
107+
"send_batch_size": 100,
108+
"batch_timeout_s": 1.0
109+
}
93110
}
94111
}
95112
```
@@ -171,6 +188,71 @@ Backend-specific settings are nested under their backend name:
171188

172189
API keys for these providers are **not** stored in `config.json` — they live in `LEMONADE_<PROVIDER>_API_KEY` env vars (persistent) or `lemond` process memory via `POST /v1/cloud/auth` (ephemeral). Manage providers with `lemonade cloud install/uninstall/auth/list` rather than editing this section by hand.
173190

191+
**telemetry** — Unified telemetry and tracing configurations:
192+
193+
| Key | Type | Default | Description |
194+
|-----|------|---------|-------------|
195+
| `enabled` | bool | false | Enable or disable telemetry tracing. |
196+
| `hide_inputs` | bool | false | Redact prompt message content from spans. |
197+
| `hide_outputs` | bool | false | Redact generated assistant message content from spans. |
198+
| `hide_thinking` | bool | false | Redact reasoning/thought content from spans. |
199+
| `max_queue_capacity` | int | 1000 | The maximum capacity of the in-memory telemetry queue buffer. Oldest spans are dropped when full. Must be `> 0`. |
200+
| `otlp` | object | (nested object) | Sub-block grouping OTLP transport details (see below). |
201+
202+
**telemetry.otlp** — Nested OTLP settings:
203+
204+
| Key | Type | Default | Description |
205+
|-----|------|---------|-------------|
206+
| `endpoint` | string | "http://localhost:4317" | The OTLP endpoint to send traces to. |
207+
| `protocol` | string | "http/protobuf" | Supported OTLP trace protocol: `"http/protobuf"` or `"http/json"`. |
208+
| `semantics` | array of strings | ["openinference", "otel_genai"] | Active trace semantics. Supported values: `"openinference"` and `"otel_genai"`. |
209+
| `headers` | object | {} | Map of custom HTTP headers to pass to the OTLP receiver. |
210+
| `max_retries` | int | 0 | Maximum number of retry attempts for failed exports. Set to `0` to disable retries and discard failed spans immediately. Must be `>= 0`. |
211+
| `retry_backoff_base_s` | double | 5.0 | Base delay in seconds for exponential backoff retries. Must be `>= 0`. |
212+
| `send_batch_size` | int | 100 | Target maximum number of spans to group in a single batched OTLP request. Must be `>= 1`. |
213+
| `batch_timeout_s` | double | 1.0 | Maximum time to wait in seconds before exporting a partially filled batch of spans. Must be `> 0`. |
214+
215+
#### Environment Variables
216+
217+
The following environment variables can be used to override telemetry configuration at runtime:
218+
219+
- **`TELEMETRY_ENABLED`**: Overrides `telemetry.enabled` (e.g. `true` or `false`).
220+
- **`TELEMETRY_HIDE_INPUTS`**: Overrides `telemetry.hide_inputs`.
221+
- **`TELEMETRY_HIDE_OUTPUTS`**: Overrides `telemetry.hide_outputs`.
222+
- **`TELEMETRY_HIDE_THINKING`**: Overrides `telemetry.hide_thinking`.
223+
- **`TELEMETRY_MAX_QUEUE_CAPACITY`**: Overrides `telemetry.max_queue_capacity`.
224+
- **`TELEMETRY_OTLP_ENDPOINT`**: Overrides `telemetry.otlp.endpoint`.
225+
- **`TELEMETRY_OTLP_PROTOCOL`**: Overrides `telemetry.otlp.protocol`.
226+
- **`TELEMETRY_OTLP_SEMANTICS`**: Overrides `telemetry.otlp.semantics` (comma-separated list of semantics, e.g., `openinference,otel_genai`).
227+
- **`TELEMETRY_OTLP_HEADERS`**: Overrides `telemetry.otlp.headers` (specified as comma-separated key-value pairs, e.g., `key1=val1,key2=val2`).
228+
- **`TELEMETRY_OTLP_MAX_RETRIES`**: Overrides `telemetry.otlp.max_retries`.
229+
- **`TELEMETRY_OTLP_RETRY_BACKOFF_BASE_S`**: Overrides `telemetry.otlp.retry_backoff_base_s`.
230+
- **`TELEMETRY_OTLP_SEND_BATCH_SIZE`**: Overrides `telemetry.otlp.send_batch_size`.
231+
- **`TELEMETRY_OTLP_BATCH_TIMEOUT_S`**: Overrides `telemetry.otlp.batch_timeout_s`.
232+
233+
#### Telemetry and Tracing Details
234+
235+
Lemonade uses a unified telemetry subsystem to trace requests and capture critical execution spans. The following technical behaviors apply:
236+
237+
- **Multi-Standard Semantic Conventions**: Supports exporting traces using two co-existing semantics:
238+
- **OpenInference**: Uses Arize Phoenix-compatible properties (always prefixed with `openinference.span.kind`, `llm.model_name`, `llm.token_count.*`).
239+
- **OpenTelemetry GenAI**: Uses standard OpenTelemetry GenAI properties (`gen_ai.system`, `gen_ai.request.model`, `gen_ai.usage.input_tokens`, `gen_ai.input.messages`, `gen_ai.output.messages`).
240+
When both semantics are specified in `telemetry.otlp.semantics`, trace spans carry attributes for both conventions in a single network payload. This allows the collector to parse either convention without duplicate network requests.
241+
- **Dynamic Attribute Prefixing**: Span attributes are dynamically prefixed based on the query type to simplify filtering:
242+
- `llm.*` for standard chat and completion spans.
243+
- `embedding.*` for text embedding generation spans.
244+
- `reranker.*` for document reranking spans.
245+
- **Token Tracking**: Captures and reports token usage metrics using semantic attributes depending on the enabled semantics:
246+
- For **OpenInference**: Token count is prefixed with `llm.token_count` across all span kinds (`llm.token_count.prompt`, `llm.token_count.completion`, `llm.token_count.total`) alongside legacy keys like `llm.usage.prompt_tokens`.
247+
- For **OpenTelemetry GenAI**: Token count uses standard fields like `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens`.
248+
- **Calculated Performance Metrics**: In streaming mode, the server automatically computes and records throughput (`llm.performance.tokens_per_second` / `gen_ai.usage.tokens_per_second` depending on semantic conventions) and prefill latency (`llm.performance.time_to_first_token` / `gen_ai.performance.time_to_first_token`) if not natively returned by the backend (e.g., for vLLM and Cloud models).
249+
- **vLLM Engine Telemetry**: For the vLLM backend, the server queries the local `/metrics` endpoint on completion to attach scheduler queue metrics (`llm.vllm.num_requests_waiting`, `llm.vllm.num_requests_running`, `llm.vllm.num_requests_swapped`) and KV cache utilization (`llm.vllm.gpu_cache_usage_factor`, `llm.vllm.cpu_cache_usage_factor`) directly to the trace spans.
250+
- **Reasoning Model Support**: For reasoning models (e.g., DeepSeek models), the server extracts and records `reasoning_content` from the assistant's generation. Any variant thought-termination tags (e.g., `</think|>`) are automatically standardized to the canonical `</think>` tag.
251+
- **Exporter Retry Backoff**: When retries are enabled (i.e., `max_retries > 0`), the exporter uses an exponential backoff strategy combined with randomized jitter for failed posts. The base retry interval starts at `retry_backoff_base_s` seconds (defaulting to 5), doubling on each subsequent failure (e.g., 5s, 10s, 20s, 40s), up to a maximum cap of 60 seconds. A randomized jitter factor between `0.5` and `1.5` is applied to each calculated delay to prevent a "thundering herd" when the collector recovers. Permanent client errors (`4xx` HTTP status codes, excluding `429 Too Many Requests`) are classified as non-retryable and cause the batch to be dropped immediately to save resources.
252+
- **OTLP Trace Batching**: Spans are aggregated in an in-memory queue buffer and exported in batches to minimize network overhead and maximize compression efficiency. Batching operates on a dual-trigger system: a batch is immediately serialized and dispatched if it reaches `send_batch_size` (default: `100`), or if `batch_timeout_s` (default: `1.0` second) has elapsed since the oldest span in the batch arrived. All remaining traces are flushed cleanly to the OTel collector upon server shutdown. Users can also trigger a manual flush at any time via the `POST /internal/telemetry/flush` endpoint.
253+
- **Request Failure Tracing**: Captures request failures directly on the telemetry spans. If a model fails to load, a request is rejected by the router, or a streaming connection encounters an exception or a non-200 HTTP status code from the backend, the span is ended with `Error` status and the specific error message is attached.
254+
- **Queue Blocking & Thundering Herd Prevention**: To prevent client requests from hanging and to avoid exhausting resources when the telemetry receiver endpoint is down, Lemonade employs a fail-fast mechanism. The exporter memory buffer is strictly bounded to a capacity of `max_queue_capacity` spans (default: `1000`). When full, a head-drop (FIFO) eviction policy is applied to drop the oldest telemetry spans to make room for newer ones, prioritizing current application state. If a telemetry transmission task fails all of its retries and is dropped, the endpoint is marked as **unreachable**. While in this unreachable state, subsequent spans in the transmission queue are attempted only once and immediately dropped without backoff delay if they fail, preventing the telemetry queue from blocking server operations. A single successful span delivery to the endpoint automatically resets the unreachable state and restores normal retry behavior.
255+
174256
### Backend binary selection
175257

176258
Every `*_bin` key (e.g. `llamacpp.vulkan_bin`, `whispercpp.cpu_bin`, `sdcpp.rocm_bin`) accepts the same set of values:
@@ -185,7 +267,7 @@ Every `*_bin` key (e.g. `llamacpp.vulkan_bin`, `whispercpp.cpu_bin`, `sdcpp.rocm
185267

186268
> Note: the `latest` setting is experimental.
187269
188-
> **Important — `llamacpp.rocm_bin` version tags are channel-specific.** Each ROCm channel downloads from a different GitHub repository, so you must set the correct `rocm_channel` before pinning `rocm_bin` to a specific tag. See [Pinning to a Specific Version Tag](./llamacpp.md#pinning-to-a-specific-version-tag) for details.
270+
> Note: `llamacpp.rocm_bin` version tags are channel-specific. Each ROCm channel downloads from a different GitHub repository, so you must set the correct `rocm_channel` before pinning `rocm_bin` to a specific tag. See [Pinning to a Specific Version Tag](./llamacpp.md#pinning-to-a-specific-version-tag) for details.
189271
190272
Examples:
191273

src/cpp/cli/main.cpp

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,9 @@ struct CliConfig {
178178
std::string cloud_base_url;
179179
std::string cloud_api_key;
180180

181+
// Telemetry toggle options
182+
std::string telemetry_status;
183+
181184
// Chat REPL options
182185
bool chat_cli = false;
183186
bool chat_no_stream = false;
@@ -1171,6 +1174,11 @@ int main(int argc, char* argv[]) {
11711174
CLI::App* logs_cmd = app.add_subcommand("logs", "Open server logs in the web UI")->group("Server");
11721175
CLI::App* scan_cmd = app.add_subcommand("scan", "Scan for network beacons")->group("Server");
11731176

1177+
CLI::App* telemetry_cmd = app.add_subcommand("telemetry", "Toggle server telemetry on or off")->group("Server");
1178+
telemetry_cmd->add_option("status", config.telemetry_status, "Telemetry status: 'on' or 'off'")
1179+
->required()
1180+
->check(CLI::IsMember({"on", "off"}));
1181+
11741182
// Config commands
11751183
CLI::App* config_cmd = app.add_subcommand("config", "View or modify server configuration")->group("Server");
11761184
CLI::App* config_set_cmd = config_cmd->add_subcommand("set", "Set configuration values (e.g., llamacpp.backend=rocm port=8123)")->group("Subcommands");
@@ -1505,6 +1513,23 @@ int main(int argc, char* argv[]) {
15051513
return 0;
15061514
} else if (scan_cmd->count() > 0) {
15071515
return handle_scan_command(config);
1516+
} else if (telemetry_cmd->count() > 0) {
1517+
bool enable = (config.telemetry_status == "on");
1518+
nlohmann::json body = {{"enabled", enable}};
1519+
try {
1520+
std::string res_str = client.make_request("/internal/telemetry", "POST", body.dump(), "application/json");
1521+
auto json_res = nlohmann::json::parse(res_str);
1522+
if (json_res.contains("status") && json_res["status"] == "success") {
1523+
std::cout << "Telemetry successfully turned " << (enable ? "on" : "off") << "." << std::endl;
1524+
return 0;
1525+
} else {
1526+
std::cerr << "Failed to toggle telemetry: " << res_str << std::endl;
1527+
return 1;
1528+
}
1529+
} catch (const std::exception& e) {
1530+
std::cerr << "Error toggling telemetry: " << e.what() << std::endl;
1531+
return 1;
1532+
}
15081533
} else if (config_cmd->count() > 0) {
15091534
if (config_set_cmd->count() > 0) {
15101535
return handle_config_set(client, config_set_cmd->remaining());

src/cpp/include/lemon/backends/vllm_server.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
namespace lemon {
88
namespace backends {
99

10+
std::map<std::string, nlohmann::json> parse_vllm_metrics_text(const std::string& body);
11+
1012
class VLLMServer : public WrappedServer {
1113
public:
1214
static InstallParams get_install_params(const std::string& backend, const std::string& version);
@@ -43,6 +45,8 @@ class VLLMServer : public WrappedServer {
4345
long timeout_seconds = 0,
4446
TelemetryCallback telemetry_callback = nullptr) override;
4547

48+
std::map<std::string, nlohmann::json> get_additional_telemetry() override;
49+
4650
};
4751

4852
} // namespace backends

src/cpp/include/lemon/config_file.h

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,6 @@ static inline bool config_migrate(json& config,
7373
current_version = 2;
7474
}
7575

76-
// Run any future migrations here as the version increments.
77-
// if (current_version < 3) { ... }
78-
7976
return true;
8077
}
8178

0 commit comments

Comments
 (0)