# Oodle > Oodle is a fully managed, AI-native observability platform — a drop-in replacement for Prometheus and ELK stack at scale. Logs, metrics, traces, and Kubernetes monitoring at 1/5th the cost with zero management. This file contains all documentation content in a single document following the llmstxt.org standard. ## Datasets & Experiments Datasets let you organize test cases for systematic evaluation of your LLM application. Each dataset contains input/expected-output pairs that you can run through an LLM with attached evaluators to measure quality at scale. Navigate to **Agent Observability → Datasets** in the sidebar (). ## Creating a Dataset 1. Click **+ New Dataset** 2. Enter a name (e.g. `sms-dataset-v1`) 3. Optionally upload a CSV file to import items 4. Click **Create** :::note Creating and managing datasets requires the **Editor** or **Admin** role. ::: ### CSV Import You can bulk-import items from a CSV file (up to 50 MB). The CSV must include an `input` column (exact case). Optional columns: | Column | Description | |--------|-------------| | `input` | The user query or prompt (required) | | `expected_output` | The ideal response | | `metadata` | Additional context as JSON | A preview of the first few rows is shown before import. Partial imports are supported: if some rows fail, the successful rows are still added and a summary toast shows the error count. ## Adding Items From a dataset's detail page, click **+ New Item** to add entries manually or via CSV. ### Manual Entry Add one or more rows with **Input** (required), **Expected Output**, and **Metadata** fields. Click **+ Add another row** to add multiple items at once. ### From a Trace On any trace detail page, click **Add to Dataset**. Oodle splits the conversation into input and expected output and adds it to the dataset you select. This is useful for capturing real production examples as test cases. ## Running Experiments Experiments run your dataset items through a target and optionally score the outputs with evaluators. The target is either an **LLM connection** (a model, with an optional prompt) or a **webhook**: an endpoint you host that runs your own agent or workflow. See [Testing your own agent through a webhook](#testing-your-own-agent-through-a-webhook). 1. Open a dataset and click **Run Experiment** 2. Under **What to run**, pick **A model with a prompt** or **Your own agent** 3. Configure the experiment: | Field | Description | |-------|-------------| | **LLM Connection** | Which provider credentials to use ([configure here](/agent-observability/llm-connections)) | | **Agent** | For **Your own agent**: the endpoint that receives each item. Create, test, or manage agents under **Agent Observability → Settings → Bring Your Own Agent** | | **Evaluator LLM connection** | For **Your own agent**: the connection that LLM judges use when no selected evaluator specifies its own | | **Model** | The model to run items against | | **Evaluators** | One or more active evaluator rules to score outputs | | **Output comparers** | Evaluators that score the model's output against each item's expected output | | **Prompt** | Optional. A prompt template (by label or version) whose `{{variables}}` each item's input fills | 3. Click **Run Experiment** ### Evaluators vs. output comparers The two pickers hold different kinds of evaluator, and an evaluator appears in exactly one of them. An ordinary **evaluator** scores a generation on its own: is it helpful, is it toxic, is it grounded in the context it was given. It needs nothing but the request and the response, so it also runs against live production traffic. An **output comparer** scores the generation against the dataset item's **expected output**: does the model's answer mean the same thing as the answer you recorded. That makes it a regression test rather than a quality score, and it only works where ground truth exists. Live traffic has none, so a comparer runs in experiments only. Two managed comparers ship with Oodle: | Comparer | What it scores | |----------|----------------| | **Output Match** | Whether the actual output is equivalent in meaning to the expected output | | **Output Completeness** | What fraction of the expected content the actual output covers | You can also write your own: pick **Output Comparer** when creating an evaluator template. An experiment fills exactly three variables for it: | Variable | Filled with | |----------|-------------| | `{{input}}` | the dataset item's input | | `{{expected_output}}` | the dataset item's expected output | | `{{output}}` | what the model produced | Any other `{{variable}}` is left in the prompt as written, so the judge is handed the placeholder text rather than a value. An output comparer has no filters, sampling rate, hourly cap or variable mapping, and no Active/Paused state. Those all describe running against live traffic, which a comparer never does: it runs when an experiment names it, and Oodle supplies the three variables above from the dataset item. :::note An item with no expected output is **skipped** by every output comparer, not scored zero. The run reports how many comparisons it skipped, so a dataset that was never given ground truth does not read as a failing model. ::: ### How the item input reaches the model The **Prompt** field decides how each item's `input` becomes the request. #### With a prompt The item's `input` supplies the template's `{{variables}}`: | Item `input` | What it fills | |--------------|---------------| | An object, `{"question": "Where is my order?", "tone": "brief"}` | Each key fills the variable of the same name: `{{question}}`, `{{tone}}` | | A plain string, `"Where is my order?"` | Fills `{{input}}` | | A message array, `[{"role": "user", ...}]` | Supplies no variables. Run conversation datasets without a prompt | So the template `Answer in {{tone}}: {{question}}` with the object input above sends `Answer in brief: Where is my order?`. A dataset of plain strings works the same way through the one `{{input}}` variable: the template `Answer briefly: {{input}}` sends `Answer briefly: Where is my order?`. Use an object input when a template needs more than one value. Matching is on the exact key, and a variable name is word characters only: `{{question}}` is a variable, `{{user.name}}` is literal text. A variable with no matching value stays in the prompt exactly as written, so `{{user_name}}` reaches the model as those characters and the reply usually talks about the placeholder rather than the item. The Run Experiment form lists the selected prompt version's variables and marks the ones no item in the dataset supplies, so a mismatch is visible before you spend a run. Renaming a dataset key or a template variable so the two agree is what fixes it. #### With no prompt The item's `input` **is** the request: a message array is sent as chat messages, any other value as its JSON text. That is the shape for a dataset that already holds full prompts or captured conversations. `expected_output` is never sent to the model either way. It reaches evaluators only, as the ground truth they compare the output against. The experiment runs asynchronously. Results appear in the **Runs** tab. :::tip If you don't have an LLM connection configured yet, the form links to **Agent Observability → Settings → LLM Connections** where you can add one. ::: ## Testing your own agent through a webhook An LLM connection tests one model call. A webhook tests the whole workflow: Oodle posts each dataset item to an endpoint you host, your agent does whatever it does with it, and the reply is scored the same way a model's would be. Every request carries a W3C `traceparent` header, so each result row opens the trace your agent emitted for that item. Pick **Your own agent** on the Run Experiment form and choose a webhook, or create one inline. A run against a webhook has no generation connection, so the LLM judges and output comparers you pick need one of their own: an evaluator that names its own connection uses it, and the **Evaluator LLM connection** field covers any that do not. The evaluators can read the trace as well as the answer: a judge prompt takes `{{trajectory}}`, and a code evaluator gets `ctx.trace.spans`, so a rule can assert on the tools the agent called and not only on what it said. See [Bring Your Own Agent](/agent-observability/experiment-webhooks) for every parameter, the request template placeholders, the trace check, trajectory evaluation, and the CLI, Terraform and MCP equivalents. ## Alerting on experiment scores Every run pushes what its scorers said to your instance as metrics, tagged with the dataset, so a monitor can watch an output comparer or an evaluator without anyone opening the Runs tab. They are delta counters: each run adds its own sum and count per score. | Metric | Labels | Meaning | |--------|--------|---------| | `oodle_genai_experiment_score_sum` | `dataset_id`, `dataset_name`, `score_name`, `scorer`, `target`, `data_type` | Sum of the score values a run wrote. A boolean counts as 0 or 1 | | `oodle_genai_experiment_score_count` | same, plus `category` for a categorical score | How many scores were written | | `oodle_genai_experiment_score_min`, `_max` | same as the sum | Gauges: the lowest and highest value one item scored in the run | | `oodle_genai_experiment_score_failures_total` | `dataset_id`, `dataset_name`, `score_name`, `scorer`, `target` | Scorers that raised and wrote no score | | `oodle_genai_experiment_items_total` | `dataset_id`, `dataset_name`, `target`, `status` | Items that `completed` or `failed` | `scorer` is `output_comparer` or `evaluator`; `target` is `llm` or `webhook`; `score_name` is the evaluator's name. The mean score over the runs of the last day, per dataset and scorer: ```promql sum by (dataset_name, score_name) ( increase(oodle_genai_experiment_score_sum[1d]) ) / sum by (dataset_name, score_name) ( increase(oodle_genai_experiment_score_count[1d]) ) ``` A monitor on that expression with a threshold such as `< 0.9` for `scorer="output_comparer"` fires when the comparer's pass rate on a dataset drops. Set the window to cover at least one scheduled run, or the expression has nothing to read between runs. The share of a categorical verdict is the same ratio with `category=""` on the numerator. A mean hides the one item that scored zero. To fire on any single item, read the run's low instead: ```promql min_over_time( oodle_genai_experiment_score_min{dataset_name="support-faq"}[1d] ) < 0.5 ``` ## Running on a Schedule A dataset can run its experiment on its own, so a prompt or model change is caught by the next scheduled run rather than the next time someone remembers to press the button. 1. Open a dataset and go to the **Schedule** tab 2. Under **When it runs**, pick one of the two shapes: **At set times**, for a run that has to land at a time of day you care about (before standup, after a nightly data load): | Field | Description | |-------|-------------| | **Days of week** | Which days fire. Leave all unselected to run every day | | **Start times** | One or more `HH:MM` times. Each one is a separate run over the whole dataset | | **Days of month** | Optional. With days of week also set, a day has to match both | | **Timezone** | The zone the start times are read in | **On a period**, for a run whose cadence matters but whose wall-clock time does not: every *N* minutes, hours or days. The shortest period is 5 minutes, and the first run happens one period after you save. No timezone applies, because a duration is the same length everywhere. 3. Under **What each run does**, configure the same connection, model, evaluators, output comparers and prompt the Run Experiment form takes 4. Click **Create schedule** Each firing creates its own run under the **Runs** tab, numbered like a manual run. The tab shows the next and last run times, and if a scheduled run could not start (a deleted LLM connection, say) it shows why. A run starts shortly after its scheduled time rather than exactly on the minute. :::note The next run time is set when a run is **queued**, not when it finishes, so a long or failed run never delays or stops the schedule. If a schedule misses several runs (an outage, or the dataset's workers were down), it does **not** replay them. One catch-up run happens and the schedule then resumes its normal cadence. ::: :::note Times are read in the schedule's timezone, so a `09:00` schedule stays at nine o'clock local across daylight saving changes rather than drifting by an hour. ::: Turn the **Enabled** switch off to stop a schedule firing while keeping its definition. ### Comparing judges on a labeled dataset When a dataset carries expected outputs, a run also reports how each evaluator **agreed** with them, what it **cost**, and how long it took, so one run over one dataset compares the models behind your evaluators. Each rule you select judges on its **own** LLM connection and model, and each is its own column: a Jev rule, a Haiku rule and an Opus rule that share one template sit in one run as three columns. **A judge is compared only with a label that names it.** Write the item's expected output as a JSON object, keyed by the **template** name, with one label per evaluator: ```json {"positive": true, "toxic": false} ``` Every evaluator built from the `positive` template — a Jev one, a Haiku one, an Opus one — reads the `positive` label, which is what puts them in one comparison. A **plain** expected output (`"no"`, `4`, `true`) is *not* read as a label for any judge. It is the answer the model under test was supposed to give, which is what an [output comparer](/evaluators/llm-as-judge) reads. A judge answers a different question about that answer, so comparing the two scores a correct judge as wrong: an item expecting `"no"` whose model answered `"no"` makes a correctness judge score 1.0, and against `"no"` that reads as a disagreement. Such a run reports no agreement rather than a wrong one. Once the label names the judge, agreement follows the score's shape: | Score | Label | Agrees when | |-------|-------|-------------| | any score that names its own verdict | a word | the words match, ignoring case | | numeric or boolean | `true`/`false`, `yes`/`no`, `pass`/`fail` | the score is at or above 0.5 exactly when the label is true | | numeric | a number | the score is within 0.5 of it | The first row matters for a decision model: Jev answers on its own **Threshold**, so a `P(yes)` of 0.4 is a "yes" at a threshold of 0.3. The verdict it decided on is compared, not a verdict re-derived from the number at a half. A score with nothing comparable to read is left out of the agreement count rather than counted as wrong. An output comparer reports no agreement at all: its score already is the comparison with the expected output. The run's score chips show the agreement next to the average; the tooltip adds cost (from the model each score names) and p50/p95 latency. ### Running evaluators as a chain An evaluator can [depend on another](/evaluators/dependencies). On live traffic a dependent evaluator scores only the spans its parents flagged. In an experiment every selected evaluator scores every item, unless you tick **Run as a chain**: then a child scores an item only where every parent it depends on found something, and the run's score counts show how many items the expensive judge never had to see. The option appears when a selected evaluator depends on another selected one, and it is off by default, so runs and schedules created before it existed keep scoring every item. With the option on, every parent must be in the run. The run is refused when a selected evaluator depends on one you did not select, or when evaluators depend on each other in a cycle. Without its parent, the child would score items the parent was set to exclude. Add the parent to the run, or clear the option. ## Viewing Results The **Runs** tab shows all experiment runs with their status, LLM connection, evaluator, prompt reference, and creation time. Click any run to open a detail drawer showing: - **Run metadata**: status, connection, model, evaluators, prompt, and any errors - **Per-item results**: a table with Input, Metadata, Expected Output, Actual Output, and score badges from each evaluator While an experiment is running, the drawer polls for new results every few seconds and shows a progress indicator. ### Run Statuses | Status | Meaning | |--------|---------| | Pending | Queued, not yet started | | Running | Processing dataset items | | Completed | All items processed | | Failed | An error stopped the run | --- ## Bring Your Own Agent An [LLM connection](/agent-observability/llm-connections) tests one model call. Bring Your Own Agent tests your whole agent through a webhook. Oodle posts each item of a [dataset](/agent-observability/datasets) to an endpoint you host. Your agent processes the item. Oodle scores the reply with the same evaluators and output comparers used for model outputs. Each result row links to the trace your agent emitted for that item. Evaluators can also read the trace. Navigate to **Agent Observability → Settings → Bring Your Own Agent** (), or pick **Create one** on the Run Experiment form. :::note Only **Admin** users can create and manage agents. Any user who can run an experiment can pick one. Header values are never shown again after they are saved: the editor shows them masked and keeps them unless you type a new one. ::: ## How a run works 1. Oodle reads the dataset and, for each item, renders the webhook's **request body** from the item. 2. It sends one `POST` per item to the **URL**, with the webhook's headers plus `Content-Type`, `traceparent` and `X-Oodle-Experiment`. Items run in parallel. 3. Any 2xx reply is accepted. The **output path** picks the output out of the reply, and the **output regex**, if set, keeps only part of it; that text is stored on the run item. 4. If an evaluator will read it, Oodle waits for the agent's trace to arrive (up to 30 seconds) and turns it into a trajectory. 5. The evaluators and output comparers score the output, and each score is written to the run item and to the instance as a [metric](/agent-observability/datasets#alerting-on-experiment-scores). ## Parameters | Parameter | API field | Required | Description | |-----------|-----------|----------|-------------| | **Name** | `name` | yes | Unique per instance. The Run Experiment form selects an agent by name | | **Description** | `description` | no | What the endpoint runs | | **URL** | `url` | yes | Where each item is posted. Absolute `http(s)`, reachable from the internet; loopback, private and cluster-local hosts are refused. For a local agent, expose it with a tunnel such as ngrok | | **Authentication** | part of `headers` | no | None, a bearer token (`Authorization: Bearer …`) or basic auth. A view of the `Authorization` header | | **Headers** | `headers` | no | Any other headers your endpoint needs, as a name to value map. Stored encrypted; the API returns only the names, and a write that sends `********` for a name keeps its stored value | | **Timeout** | `timeoutSeconds` | no | How long one item may take, 1 to 600 seconds. Default 60. A test from the form is capped at 120 | | **Request body** | `requestTemplate` | no | JSON text with `{{path}}` placeholders. Default `{{input}}`: the item's input is the whole body | | **Output path** | `outputPath` | no | Where the output is in the reply. Empty stores the whole reply | | **Regex** | `outputRegex` | no | Applied to what the output path picked. The first capture group, or the whole match, is the output. Empty keeps the output whole | The headers `traceparent`, `tracestate`, `X-Oodle-Experiment`, `Host` and `Content-Length` are Oodle's and cannot be set. ### Request body A placeholder is replaced by the JSON of the value it names, so quoting and escaping are handled for you. In the form, type `{{` to see the placeholders; the list includes the fields of the sample item's input. | Placeholder | Reads | |-------------|-------| | `{{input}}` | The item's whole input | | `{{input.}}` | One field of an object input, such as `{{input.question}}`. Nest with dots and `[n]`: `{{input.messages[0].content}}` | | `{{metadata}}`, `{{metadata.}}` | The item's metadata | | `{{id}}` | The dataset item id | | `{{run.id}}`, `{{run.name}}` | The run's id and name (a number). `test` on a Send test | | `{{dataset.id}}`, `{{dataset.name}}` | The dataset the run reads | For an endpoint that takes `{"query": "...", "user": "eval"}`: ```json {"query": {{input.question}}, "user": "eval"} ``` A placeholder whose path finds nothing renders as `null`. The expected output is never available to the template: it is ground truth for the evaluators only. ### Output path A dot path over the reply, with `[n]` for an array index: `answer`, `result.text`, `choices[0].message.content`. A string value is stored as is; anything else is stored as its JSON. A reply with nothing at the path fails the item. ### Regex When the answer is only part of a text value, add a regex after the path (**+ Regex** beside the output path). The output is the first capture group of the first match, or the whole match when the regex has no group. `.` also matches a new line, so a group can hold an answer of several lines. For a reply `{"answer": "Thinking... Final answer: 42"}`, the output path `answer` and the regex `Final answer: (.*)` store `42`. The syntax is [RE2](https://github.com/google/re2/wiki/Syntax). Lookahead, lookbehind and backreferences are not available. Put flags at the start of the regex, for example `(?i)` to ignore case. A reply that does not match the regex fails the item. ### What counts as a failure A non-2xx status, a timeout, a reply larger than 4 MB, a reply with nothing at the output path, or an output that does not match the regex fails that item, and the reason is shown on the item's row. A 429, 502, 503 or 504 is retried once, with a fresh trace id. A timeout is not retried. ## Testing a webhook The editor shows the body it will send for a sample item as you type. Opened from a dataset's Run Experiment form, the sample is one of that dataset's items; otherwise it is any input you type. **Send test** posts that body and shows three things: the reply, the output the path and regex picked out, and whether a trace with the request's trace id has arrived in Oodle. Change the output path or the regex after a test and the output updates from the same reply, so you do not need to send the test again. The trace check keeps looking for a minute after the reply. Spans usually arrive a few seconds behind it, so "no trace yet" beside a reply is not a failure until the minute is up. ## Linking your agent's trace to each result Every request carries a W3C `traceparent` header holding a fresh trace id. A service with OpenTelemetry HTTP instrumentation continues that trace, so every span your agent emits while handling the item lands in Oodle under the same id and the result row opens it: the LLM calls, the tool calls, and what each one did. Nothing else is needed. If your framework is already sending traces to Oodle (see the [Agent Observability integrations](/integrations/agent-observability)), the server instrumentation that reads the incoming header is usually part of the same setup. ### Telling experiment traffic apart Every request also carries `X-Oodle-Experiment: `, so your agent can tell an experiment from production traffic. Two uses: - **Tag the traces.** Capture the header as a span attribute and the traces page can filter on it. With OpenTelemetry's server instrumentation that is one setting, no code: ```bash # Python (FastAPI, Flask, Django, ASGI/WSGI) OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=x-oodle-experiment # Java agent -Dotel.instrumentation.http.server.capture-request-headers=x-oodle-experiment ``` The attribute is `http.request.header.x_oodle_experiment` on the server span. - **Keep experiments out of production numbers.** Read the header and set `deployment.environment` (or whatever your agent uses for its environment) to something like `experiment` on the trace, so dashboards, insights and live evaluators filtered to production do not count it. ## Evaluating the trajectory, not only the answer An output comparer or LLM judge sees the item's input, the expected output and the output. On a webhook run it can also see how the agent got there: the steps of the trace, in order, with each tool call's name, arguments and result, each model call, its duration and whether it failed. - In a judge or comparer prompt, use `{{trajectory}}`. It is filled with numbered lines such as: ```text 1. [agent] invoke_agent 2000ms 2. [tool] lookup_order 300ms ERROR: timeout in: {"id": 42} 3. [llm] ChatOpenAI.chat (gpt-4o) 1500ms ``` - In a code evaluator, `ctx.trace.spans` is the same list as dicts (`kind`, `name`, `tool_name`, `model`, `input`, `output`, `duration_ms`, `error`, `error_message`), and `ctx.trace.tools_called()` lists the tool names in order: ```python def evaluate(ctx): called = ctx.trace.tools_called() return EvaluationResult(scores=[ Score(name="looked_up_order", value=1.0 if "lookup_order" in called else 0.0), Score(name="steps", value=len(ctx.trace.spans)), ]) ``` Oodle waits up to 30 seconds after the reply for the trace to finish arriving, and only when an evaluator will read it. Long arguments and results are cut at 2,000 characters; the whole trace is on the run item's row. On an LLM connection run, and on a webhook run whose trace never arrived, the trajectory is empty. ## Running an experiment against a webhook On the Run Experiment form, choose **Your own agent** and pick the webhook. No prompt or model is asked for: the endpoint owns both. A run against a webhook has no generation connection, so the LLM judges and output comparers you pick need one of their own. An evaluator that names its own connection uses it; the **Evaluator LLM connection** field covers any that do not. Code evaluators run no model and need none. Results read the same as any other run. Each row shows the output, its scores, and the trace, with the span count, latency, tokens and cost the trace carried. ## API Under `/v1/api/instance/{instance}/langfuse/api/public/`: | Method and path | Purpose | |-----------------|---------| | `GET webhooks` | List webhooks (header names only) | | `POST webhooks` | Create one. Body: the parameters above, `headers` as a map | | `GET webhooks/{id}` | One webhook | | `PUT webhooks/{id}` | Update. Omitted fields keep their value; omitted `headers` keeps the stored ones, and a header sent as `********` keeps that one's stored value | | `DELETE webhooks/{id}` | Delete | | `POST webhook-tests` | Send one request the way a run would. Body: `webhookId` or the parameters inline, plus `input` or `datasetItemId`. Returns the request sent (credentials masked), the reply, the extracted output and the trace id | | `POST jobs` with `type: "llm-experiment"` and `config.webhookId` | Queue a run. `config.evalConnectionId` for the judges | ## CLI The [Oodle CLI](https://github.com/oodle-ai/oodle-cli) manages webhooks under `oodle genai webhooks` and runs experiments against one with `--webhook-id`: ```bash oodle genai webhooks create --name "Support agent" \ --url https://agent.example.com/run \ --header "Authorization=Bearer $AGENT_TOKEN" \ --request-template '{"query": {{input.question}}, "run": {{run.name}}}' \ --output-path answer --timeout 120 oodle genai webhooks test "$WH" --input '{"question": "Is checkout slow?"}' oodle genai experiments run --dataset-id "$DS" --webhook-id "$WH" \ --output-comparer-id oodle-managed-output-match-v1 \ --eval-connection-id "$CONN" ``` `list`, `get`, `update -f ` and `delete` complete the set. Header values are encrypted at rest and never returned; `get` shows their names. ## Terraform The [Oodle Terraform provider](https://registry.terraform.io/providers/oodle-ai/oodle/latest/docs) has an `oodle_genai_webhook` resource, so a webhook can live beside the service it points at: ```hcl resource "oodle_genai_webhook" "support_agent" { name = "support-agent-staging" url = "https://agent-staging.example.com/run" headers = { Authorization = "Bearer ${var.agent_token}" } request_template = "{\"query\": {{input.question}}}" output_path = "answer" timeout_seconds = 120 } ``` Header values are stored encrypted and the API returns only their names, so keep them in a variable or a secret store. ## MCP Through the [Oodle MCP server](/integrations/mcp), an AI assistant can manage webhooks with `manage_genai_webhooks` (`list`, `get`, `create`, `update`, `delete`; header values are never returned, and an update that sends `********` for a header keeps its stored value) and queue a run against one with `manage_genai_experiments`: ```json {"action": "run", "datasetId": "", "webhookId": "", "outputComparerIds": ["oodle-managed-output-match-v1"], "evalConnectionId": ""} ``` A credential still has to be typed somewhere to be stored, so an assistant should create the webhook without one and point you at the editor to add it, rather than ask for it in the conversation. --- ## Agent Observability Oodle's Agent Observability helps engineering teams track how large language models are used across their applications - including prompts, responses, token consumption, and estimated costs. New to Oodle? [Sign up for free](https://us1.oodle.ai/signup), then pick your SDK or framework on the [Agent Observability integrations](/integrations/agent-observability) page: each one is a paste-ready OpenTelemetry snippet. ## How It Works Oodle ingests traces and logs that follow the [OpenTelemetry Gen AI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/). When your application instruments LLM calls with these conventions, Oodle automatically: - Detects the **model** from `gen_ai.request.model` and `gen_ai.response.model` span attributes - Extracts **input and output token counts** from `gen_ai.usage.input_tokens` and `gen_ai.usage.output_tokens` - Displays **prompt and response content** from `gen_ai.input.messages`, `gen_ai.output.messages`, and `gen_ai.system_instructions` span attributes - Calculates **estimated costs** based on your configured [model pricing](./model-definitions) All of this is surfaced in the Agent Observability hub at **Agent Observability → Overview** (). ## Exploring Traces The Agent Observability hub has several tabs for different views of your GenAI data. ### Overview The default tab shows an embedded Grafana dashboard with key metrics: token usage, cost trends, trace counts, and model breakdowns over time. ### Traces The main trace list with columns for timestamp, name, input, output, model, sentiment, turns, latency, and cost. Search traces by **Name**, **Input**, **Output**, or **Input/Output** combined using the field dropdown next to the search box. Click any trace to open the detail view (see [Trace Detail](#trace-detail) below). ### Agent Graph An interactive topology graph showing how agents, tools, and LLM calls connect in your application. Each node displays request count, error rate, and average latency. Click a node to see: - **Decision Paths** — outgoing connections split into sub-agents and tool calls with probability, error rate, and latency - **Traces** — filtered trace list for that agent - **Insights** — AI-generated recommendations specific to that agent (if available) The Agent Graph has its own sidebar with environment and service filters, plus a node search and visibility toggle. ### Observations A span-level table showing individual LLM calls rather than full traces. Columns include start time, span type, name, input, output, model, sentiment, log level, latency, and cost. ### Sessions Groups traces by `session.id` into multi-turn conversations. Each row shows the session ID, trace count, turns, sentiment, total cost, duration, and last activity time. Click a session to open a detail drawer with stats and a cross-trace transcript. :::tip To enable sessions, set `session.id` as a span attribute in your instrumentation. Traces sharing the same session ID are grouped together automatically. ::: ### Users Aggregates usage by `user.id` — showing total events, turns, sentiment, token count, and cost per user. Click a user to filter the Traces tab to that user's activity. :::tip Set `user.id` as a span attribute to enable per-user tracking. ::: ### Insights Appears when Oodle has generated AI-powered recommendations for your GenAI application. Insights are categorized by type (Quality, Efficiency, Resource, Performance) and severity (High, Medium, Low). Use this tab to identify optimization opportunities and quality issues. ## Sidebar Filters The sidebar on the left lets you filter data across most tabs: | Filter | Description | |--------|-------------| | **Time Range** | Controls the query window | | **Has Error** | Show only traces with errors | | **Environment** | Filter by `resource::env` | | **Service** | Filter by `resource::service.name` | | **Model** | Filter by `gen_ai.request.model` | | **Agent Name** | Filter by `gen_ai.agent.name` | | **Operation Name** | Filter by `gen_ai.operation.name` | | **User ID** | Filter by `user.id` | | **Sentiment** | Range slider from −1 to 1 | | **Score** | Range slider from 0 to 1 | | **Score Name** | Filter by evaluator score name | You can also add arbitrary label filters using the filter bar above the tab content. ## Trace Detail Clicking on a trace opens the detail view with tabs for **Transcript**, **Trace**, **Logs**, **Scores**, and **Costs**. ### Transcript Displays the full conversation — system instructions, user prompts, and assistant responses — alongside the model name and token count. Use the search bar to find text across all messages. Matches are highlighted and the view scrolls to each result. For traces with multiple agents (identified by `gen_ai.agent.name`), a **By Agent** toggle groups the conversation by agent. ### Trace Shows the span waterfall with the full request lifecycle, including nested agent invocations and LLM calls. By default, non-GenAI infrastructure spans are hidden. Toggle **Show all spans** to reveal them. GenAI-related spans (GENERATION, AGENT, TOOL) are expanded automatically while infrastructure spans start collapsed. Each span has sub-tabs for **Overview**, **Gen AI** (model/token/cost summary), **Transcript**, and **Errors**. ### Scores Shows all evaluator scores attached to the trace. Each row displays the score name, value, source (EVAL, ANNOTATION, or API), data type, model used, and comment. ### Costs Summarizes total cost, tokens, and LLM calls broken down by model. Shows separate breakdowns for model costs and evaluator costs. ### Trace Actions The trace detail toolbar includes several actions: | Action | Description | |--------|-------------| | **Analyze with AI** | Opens the AI sidebar assistant with the trace as context | | **Open in Playground** | Loads the trace conversation into the [Playground](./playground) for iteration | | **Add to Dataset** | Adds the trace as a test case to a [dataset](./datasets) | | **Open Trace** | Opens the trace in the standard trace lookup view | ## Getting Started ### 1. Choose how data reaches Oodle There are two supported paths. Both send the same OpenTelemetry data, so pick the one that matches your environment. **Via an OpenTelemetry Collector.** Your application exports to a collector you run, and the collector forwards to Oodle. Redaction, sampling and batching live in the collector, and every service uses one export path. Use this if you already run a collector. **Direct to Oodle.** Your application exports to Oodle with no collector in between. There is nothing extra to operate, but the application holds the Oodle API key and there is no collector hop to redact content. Use the SDK's own options to limit what is captured. Oodle's OTLP endpoint serves the standard OTLP signal paths (`/v1/traces`, `/v1/logs`, `/v1/metrics`) and requires the `X-API-KEY` and `X-OODLE-INSTANCE` headers. Requests without them are rejected with `401`. ### 2. Install instrumentation Install the instrumentation for your LLM provider. Google Gemini, Pydantic AI and LiteLLM are Python only: there is no OpenTelemetry instrumentation for the `@google/genai` JavaScript SDK, Pydantic AI has no JavaScript port, and LiteLLM carries its OTel callback in the Python SDK only. ```bash pip install langfuse openai \ opentelemetry-exporter-otlp-proto-http \ opentelemetry-sdk ``` ```bash npm install @langfuse/tracing @langfuse/openai \ @opentelemetry/exporter-trace-otlp-proto \ @opentelemetry/sdk-trace-base @opentelemetry/sdk-trace-node \ @opentelemetry/resources @opentelemetry/semantic-conventions openai ``` ```bash pip install openlit ``` ```bash npm install openlit ``` ```bash pip install opentelemetry-instrumentation-openai-v2 \ opentelemetry-exporter-otlp-proto-http \ opentelemetry-sdk \ httpx \ openai ``` :::note `httpx` must be installed explicitly. `openai` 3.x depends on `httpx2`, while `opentelemetry-instrumentation-openai-v2` imports `httpx` without declaring it as a dependency. Without `httpx` the instrumentor raises `ModuleNotFoundError` on import. ::: ```bash npm install @opentelemetry/instrumentation-openai \ @opentelemetry/instrumentation \ @opentelemetry/exporter-trace-otlp-proto \ @opentelemetry/resources \ @opentelemetry/sdk-trace-base \ @opentelemetry/sdk-trace-node \ @opentelemetry/semantic-conventions \ "openai@^6" ``` :::caution `@opentelemetry/instrumentation-openai` supports `openai >=4.19.0 <7`. On `openai` 7 it patches nothing and emits no spans, without an error. Keep the version pin. ::: ```bash pip install pydantic-ai \ opentelemetry-exporter-otlp-proto-http \ opentelemetry-sdk ``` ```bash pip install opentelemetry-instrumentation-google-genai \ opentelemetry-exporter-otlp-proto-http \ opentelemetry-sdk \ google-genai ``` ```bash pip install litellm \ opentelemetry-exporter-otlp-proto-http \ opentelemetry-sdk ``` LiteLLM depends on neither OpenTelemetry package. Without them the callback fails to start, LiteLLM logs one non-blocking error, and the application serves traffic and sends nothing. ```bash pip install langchain langchain-openai langgraph \ opentelemetry-instrumentation-langchain \ opentelemetry-exporter-otlp-proto-http \ opentelemetry-sdk ``` ```bash npm install @traceloop/instrumentation-langchain \ @opentelemetry/instrumentation \ @opentelemetry/exporter-trace-otlp-proto \ @opentelemetry/resources \ @opentelemetry/sdk-trace-base \ @opentelemetry/sdk-trace-node \ @opentelemetry/semantic-conventions \ langchain @langchain/openai @langchain/core ``` ```bash pip install traceloop-sdk google-genai ``` ```bash npm install @traceloop/node-server-sdk openai ``` See the [OpenTelemetry GenAI instrumentation registry](https://opentelemetry.io/ecosystem/registry/?language=python&component=instrumentation&search=gen+ai) for a full list of supported providers (Anthropic, AWS Bedrock, and more). ### 3. Instrument your application The examples below export to a collector at `http://otel-collector:4318`. For the direct path, replace that endpoint with your Oodle OTLP endpoint and add the authentication headers, as shown in step 4. :::caution JavaScript: initialization order matters OpenTelemetry auto-instrumentation patches modules as they load. The SDK initialization must run **before** the LLM client is required. If you require the client first, the application works but emits no spans. ::: The [Langfuse](https://langfuse.com) SDK is built on OpenTelemetry, so its spans can be sent to Oodle like any other instrumentation. Oodle resolves the `langfuse.*` attributes to the standard `gen_ai.*` attributes when it ingests them. ```python from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.trace import set_tracer_provider from langfuse import Langfuse, get_client, observe def setup_opentelemetry(): resource = Resource.create({"service.name": "my-llm-app"}) tracer_provider = TracerProvider(resource=resource) tracer_provider.add_span_processor( BatchSpanProcessor(OTLPSpanExporter()) ) set_tracer_provider(tracer_provider) return tracer_provider # Langfuse writes into the application tracer provider. Langfuse(tracer_provider=setup_opentelemetry()) # The Langfuse OpenAI drop-in traces every call it makes. from langfuse.openai import openai @observe() def chat(message: str) -> str: response = openai.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": message}], ) return response.choices[0].message.content chat("Hello!") # Short-lived processes must flush before they exit. get_client().flush() ``` ```typescript // instrumentation.ts export const provider = new NodeTracerProvider({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'my-llm-app' }), spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())], }); provider.register(); // Langfuse writes into this provider. setLangfuseTracerProvider(provider); ``` ```typescript // index.ts - import the instrumentation first. const openai = observeOpenAI(new OpenAI()); const chat = observe( async (message: string) => { const response = await openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: message }], }); return response.choices[0].message.content; }, { name: 'chat' }, ); async function main() { await chat('Hello!'); // Short-lived processes must flush before they exit. await provider.forceFlush(); } main(); ``` :::caution Attach Langfuse to the application tracer provider, as shown. If you register `LangfuseSpanProcessor` **and** an OTLP span processor in the same provider, every Langfuse span is exported twice, which double counts tokens and cost. See the Langfuse guide on [using an existing OpenTelemetry setup](https://langfuse.com/faq/all/existing-otel-setup). ::: :::note `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` must be set even when the OTLP exporter owns transport. Without them the Langfuse client disables itself and emits nothing. Any value works in that case. ::: [OpenLIT](https://github.com/openlit/openlit) auto-instruments supported LLM providers, vector databases, MCP tools, and GPUs with a single `init()` call. ```python openlit.init( service_name="my-llm-app", otlp_endpoint="http://otel-collector:4318", capture_message_content=True, ) # Import your LLM SDK after openlit.init so it gets patched. ``` ```javascript const Openlit = require('openlit').default; Openlit.init({ applicationName: 'my-llm-app', otlpEndpoint: 'http://otel-collector:4318', captureMessageContent: true, }); // Require your LLM SDK after Openlit.init. const OpenAI = require('openai'); ``` Set `capture_message_content` to capture prompt and response content in span attributes. Review your organization's data policies before you enable it in production. ```python from opentelemetry import _logs as otel_logs from opentelemetry import trace as otel_trace from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk._logs.export import BatchLogRecordProcessor from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor def setup_opentelemetry(): resource = Resource.create({"service.name": "my-llm-app"}) tracer_provider = TracerProvider(resource=resource) tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) otel_trace.set_tracer_provider(tracer_provider) logger_provider = LoggerProvider(resource=resource) logger_provider.add_log_record_processor( BatchLogRecordProcessor(OTLPLogExporter()) ) otel_logs.set_logger_provider(logger_provider) setup_opentelemetry() OpenAIInstrumentor().instrument() ``` Put the setup in `instrumentation.js` and load it before the application with `node --require ./instrumentation.js app.js`: ```javascript // instrumentation.js const { OpenAIInstrumentation } = require('@opentelemetry/instrumentation-openai'); const { registerInstrumentations } = require('@opentelemetry/instrumentation'); const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-proto'); const { resourceFromAttributes } = require('@opentelemetry/resources'); const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base'); const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node'); const { ATTR_SERVICE_NAME } = require('@opentelemetry/semantic-conventions'); const provider = new NodeTracerProvider({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'my-llm-app' }), spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())], }); provider.register(); registerInstrumentations({ instrumentations: [new OpenAIInstrumentation()] }); process.on('beforeExit', () => provider.shutdown()); ``` :::note `NodeTracerProvider` does not read `OTEL_SERVICE_NAME`. Name the service in the `resource` as shown, or every trace arrives as `unknown_service:node`. ::: ```python from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.trace import set_tracer_provider from pydantic_ai import Agent, InstrumentationSettings def setup_opentelemetry(): resource = Resource.create({"service.name": "my-llm-app"}) tracer_provider = TracerProvider(resource=resource) tracer_provider.add_span_processor( BatchSpanProcessor(OTLPSpanExporter()) ) set_tracer_provider(tracer_provider) setup_opentelemetry() # use_aggregated_usage_attribute_names=False emits the standard # gen_ai.* usage attributes instead of aggregated ones. Agent.instrument_all(InstrumentationSettings( use_aggregated_usage_attribute_names=False, )) ``` Pydantic AI captures prompt and response content by default. Pass `include_content=False` to `InstrumentationSettings` to disable it. ```python from opentelemetry import _logs as otel_logs from opentelemetry import trace as otel_trace from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.google_genai import GoogleGenAiSdkInstrumentor from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk._logs.export import BatchLogRecordProcessor from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor def setup_opentelemetry(): resource = Resource.create({"service.name": "my-llm-app"}) tracer_provider = TracerProvider(resource=resource) tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) otel_trace.set_tracer_provider(tracer_provider) logger_provider = LoggerProvider(resource=resource) logger_provider.add_log_record_processor( BatchLogRecordProcessor(OTLPLogExporter()) ) otel_logs.set_logger_provider(logger_provider) setup_opentelemetry() GoogleGenAiSdkInstrumentor().instrument() ``` After this, all calls to the Google Gemini SDK are traced automatically. ```python # LiteLLM builds its own tracer provider from the # OTEL_EXPORTER_OTLP_* variables, so there is no SDK setup # to write here. litellm.callbacks = ["otel"] response = litellm.completion( model="gpt-4o-mini", messages=[{"role": "user", "content": "Hello!"}], ) ``` Every call LiteLLM routes is traced, whichever provider serves it. LiteLLM Proxy takes the same callback in `config.yaml`: ```yaml litellm_settings: callbacks: ["otel"] ``` LiteLLM captures prompt and response content by default. Set `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=NO_CONTENT` to disable it. ```python from langchain.agents import create_agent from langchain_openai import ChatOpenAI from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.instrumentation.langchain import LangchainInstrumentor from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.trace import set_tracer_provider def setup_opentelemetry(): resource = Resource.create({"service.name": "my-llm-app"}) tracer_provider = TracerProvider(resource=resource) tracer_provider.add_span_processor( BatchSpanProcessor(OTLPSpanExporter()) ) set_tracer_provider(tracer_provider) setup_opentelemetry() # Auto-instruments LangChain chains, LLM calls, tools, # and retrievers. No manual spans required. LangchainInstrumentor().instrument() agent = create_agent(ChatOpenAI(model="gpt-4o-mini"), tools=[]) ``` :::note Build agents with `from langchain.agents import create_agent`. `langgraph.prebuilt.create_react_agent` is deprecated in LangGraph v1. ::: ```javascript // instrumentation.js const { LangChainInstrumentation } = require('@traceloop/instrumentation-langchain'); const { registerInstrumentations } = require('@opentelemetry/instrumentation'); const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-proto'); const { resourceFromAttributes } = require('@opentelemetry/resources'); const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-base'); const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node'); const { ATTR_SERVICE_NAME } = require('@opentelemetry/semantic-conventions'); const provider = new NodeTracerProvider({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: 'my-llm-app' }), spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter())], }); provider.register(); registerInstrumentations({ instrumentations: [new LangChainInstrumentation()] }); process.on('beforeExit', () => provider.shutdown()); ``` [Traceloop's OpenLLMetry](https://github.com/traceloop/openllmetry) SDK auto-instruments supported LLM SDKs with a single init call. ```python from google import genai from traceloop.sdk import Traceloop from traceloop.sdk.decorators import workflow # Auto-instruments Gemini, OpenAI, Anthropic, LangChain, and more. Traceloop.init(app_name="my-llm-app") client = genai.Client() # Optional: group related LLM calls under a named workflow span. @workflow(name="chat") def chat(message: str) -> str: response = client.models.generate_content( model="gemini-flash-latest", contents=message, ) return response.text ``` ```javascript const traceloop = require('@traceloop/node-server-sdk'); traceloop.initialize({ appName: 'my-llm-app', baseUrl: process.env.TRACELOOP_BASE_URL, }); // Require your LLM SDK after initialize. const OpenAI = require('openai'); ``` :::note Traceloop uses its own environment variables instead of the `OTEL_*` ones. Point the SDK at your collector with `TRACELOOP_BASE_URL=http://otel-collector:4318`; the SDK appends `/v1/traces`. Content capture is on by default; disable it with `TRACELOOP_TRACE_CONTENT=false`. ::: ### 4. Set environment variables ```bash export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_and_event ``` The collector holds the Oodle credentials, so the application does not need them. ```bash export OTEL_EXPORTER_OTLP_ENDPOINT=https://-otlp.collector.oodle.ai export OTEL_EXPORTER_OTLP_HEADERS="X-API-KEY=,X-OODLE-INSTANCE=" export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=span_and_event ``` In JavaScript, set `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` instead. That instrumentation parses the setting as a boolean, warns on any other value, and captures nothing. Traceloop uses `TRACELOOP_BASE_URL` and `TRACELOOP_HEADERS` instead. LiteLLM also reads `OTEL_SERVICE_NAME`. It has no code hook for the resource, so without that variable every trace lands under the service name `litellm`. Langfuse uses the same OTLP endpoint and headers as every other provider. Oodle does serve a Langfuse-compatible ingest API, but that path is for prompt management, not for tracing: sending spans through it leaves the Langfuse SDK owning the only tracer provider, which drops every non-Langfuse span your application produces. | Variable | Description | |----------|-------------| | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint: your collector, or the Oodle OTLP endpoint for the direct path | | `OTEL_EXPORTER_OTLP_HEADERS` | Oodle authentication, direct path only | | `OTEL_SEMCONV_STABILITY_OPT_IN` | Set to `gen_ai_latest_experimental` to enable GenAI semantic conventions (not needed for Pydantic AI). Required for LiteLLM: without it the span carries no `gen_ai.operation.name`, and the Agent Observability traces list, which filters on that attribute, shows nothing | | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Captures prompt/response content. Python reads a mode name, so use `span_and_event`; the JavaScript instrumentation parses a boolean, so use `true` there. Not needed for Pydantic AI, which captures content by default | :::caution `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` captures full prompt and response text. This may include sensitive data. Review your organization's data policies before you enable it in production. On the direct path there is no collector to redact it. ::: ### 5. Configure the OpenTelemetry Collector Skip this step if you send data directly to Oodle. Route traces and logs from your application to Oodle through an OpenTelemetry Collector. The collector exports to Oodle's OTLP endpoint, which serves the standard OTLP paths (`/v1/traces`, `/v1/logs`): ```yaml receivers: otlp: protocols: http: endpoint: "0.0.0.0:4318" processors: batch: timeout: 5s send_batch_size: 512 exporters: otlphttp/oodle: endpoint: "https://-otlp.collector.oodle.ai" headers: "X-OODLE-INSTANCE": "" "X-API-KEY": "" service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [otlphttp/oodle] logs: receivers: [otlp] processors: [batch] exporters: [otlphttp/oodle] ``` Replace `` and `` with your credentials from **Settings → API Keys** in the Oodle UI (). See the [OpenTelemetry integration guide](/integrations/traces/otel) for full collector and SDK configuration options. ### 6. Verify in Oodle Navigate to **GenAI > Traces** (). Click on a trace to see the Transcript, Trace waterfall, and Costs breakdown. If no spans arrive: - **JavaScript, no spans at all**: confirm the SDK initialization runs before the LLM client is required, and that `openai` is below version 7. - **Python with OpenAI, `ModuleNotFoundError`**: install `httpx`. - **Langfuse, no spans**: confirm `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` are set. - **Langfuse spans counted twice**: the application must not add its own OTLP span processor as well as `LangfuseSpanProcessor`. - **`401` responses**: check `X-API-KEY` and `X-OODLE-INSTANCE`. ## Demo Applications The [oodle-onboarding](https://github.com/oodle-ai/oodle-onboarding) repository includes ready-to-run demos: - [**llmops-otel-demo**](https://github.com/oodle-ai/oodle-onboarding/tree/main/llmops-otel-demo) — Google Gemini with OTel instrumentation - [**pydantic-ai-demo**](https://github.com/oodle-ai/oodle-onboarding/tree/main/pydantic-ai-demo) — Pydantic AI multi-agent example with tool use and structured outputs - [**traceloop-demo**](https://github.com/oodle-ai/oodle-onboarding/tree/main/traceloop-demo) — Traceloop (OpenLLMetry) SDK auto-instrumenting Google Gemini - [**openlit-demo**](https://github.com/oodle-ai/oodle-onboarding/tree/main/openlit-demo) — OpenLIT with agents (LangGraph), guardrails, VectorDB (ChromaDB RAG), and MCP tool tracing ```bash git clone https://github.com/oodle-ai/oodle-onboarding.git cd oodle-onboarding/llmops-otel-demo cp .env.example .env # Edit .env with your OODLE_INSTANCE, OODLE_API_KEY, and GEMINI_API_KEY make up make test-chat ``` Each demo includes an OTel Collector and Docker Compose configuration — everything needed to see LLM traces in Oodle in minutes. ## Cost Tracking Once [model definitions](./model-definitions) are configured, Oodle shows cost data on every LLM trace. The cost is calculated as: ``` cost = (input_tokens / 1,000,000) × input_price + (output_tokens / 1,000,000) × output_price ``` For parent spans (agent invocations), Oodle aggregates token counts from child LLM spans and shows the total estimated cost. See the [Model Definitions](./model-definitions) page for pricing configuration. --- ## LLM Connections LLM Connections store your provider API credentials so Oodle can call LLMs on your behalf. They are required for the [Playground](/agent-observability/playground), [LLM-as-Judge evaluators](/evaluators/llm-as-judge), and [dataset experiments](/agent-observability/datasets). Navigate to **Agent Observability → Settings → LLM Connections** (). :::note Only **Admin** users can create and manage LLM connections. ::: ## Supported Providers | Provider | Default Base URL | |----------|------------------| | **Google** | `https://generativelanguage.googleapis.com` | | **Anthropic** | `https://api.anthropic.com` | | **OpenAI** | `https://api.openai.com/v1` | | **TypeSafe (Jev)** | `https://api.typesafe.ai/v1/systemone` | ### TypeSafe (Jev) Jev is a decision model, not a chat model. It answers a typed question (yes/no, one of a set of options, or a level on a scale) with a calibrated probability, and it cannot generate text. Use a TypeSafe connection in two places: - As an [evaluator](/evaluators/llm-as-judge)'s connection: the evaluator prompt is the question. - As an [experiment](/agent-observability/datasets)'s model, when the prompt template asks one question about the item's variables. The **Decision** field on the run form gives the answer's shape, one of Jev's three question types: | Decision | Criteria | Output | |----------|----------|--------| | Yes / no | none; **Threshold** is the P(yes) at or above which the answer is `yes` (0.5 unless set) | `yes` or `no` | | One of a set of options | one option per line, `option: description` | the chosen option | | A level on an ordered scale | 2 to 10 levels, one per line, lowest first | the nearest level | The output is a word, so an output comparer reads it like any model's, and you can compare Jev with chat models on the same decision task, for example "which team owns this ticket?". The Playground does not list it. To reach Jev through OpenRouter instead, set the **Base URL** to `https://openrouter.ai/api/alpha/decisions` and add `~typesafe/jev-latest` as a custom model. ## Creating a Connection 1. Click **+ New Connection** 2. Fill in the required fields: | Field | Required | Description | |-------|----------|-------------| | **Name** | Yes | Display name for the connection | | **Provider** | Yes | Google, Anthropic, OpenAI, or TypeSafe | | **API Key** | Yes | Your provider API key | | **Default Model** | No | Model used when none is explicitly selected | | **Base URL** | No | Override the default API endpoint (useful for proxies or custom deployments) | | **Custom Models** | No | Additional model names not in the built-in list (e.g. `deepseek/deepseek-v4-flash`) | | **Custom Headers** | No | Extra HTTP headers sent with every request | 3. Optionally toggle **Set as default connection** to make this the primary connection 4. Optionally toggle **Show default models from provider** off to hide built-in models and only show your custom models 5. Click **Create** ## Default Connection One connection can be marked as the default. The Playground, evaluator wizard, and experiment form auto-select the default connection when no other is specified. ## Custom Models If your provider supports models not in Oodle's built-in list, add them as **Custom Models**. These appear alongside built-in models in every model selector. You can also type any model name directly in model dropdowns — select **Use "model-name"** to use an unlisted model. ## Editing and Deleting Click the edit icon on any connection to update its configuration. When editing, leave the API key blank to keep the existing key. :::warning Deleting a connection stops any evaluators and Playground sessions that use it. ::: --- ## Model Definitions Model Definitions let you configure pricing information for each LLM model your applications use. Navigate to **Agent Observability → Model Definitions** in the sidebar (). ## How pricing works Each model definition includes: | Field | Description | |-------|-------------| | **Model Name** | Display name (e.g. `gemini-flash-latest`) | | **Match Pattern** | Regex matched against the model name in traces | | **Input Price** | Cost per 1M input tokens (USD) | | **Output Price** | Cost per 1M output tokens (USD) | When a trace span includes token usage and a model name, Oodle walks the model definitions list and applies the first matching pattern to estimate the cost. ## Adding a model 1. Go to **Agent Observability → Model Definitions** () 2. Models detected in your traces that don't match any existing pattern appear at the top of the table with a warning - click to add pricing 3. Enter the model name, a regex match pattern, and input/output prices per 1M tokens --- ## Playground The Playground is an interactive testing environment for LLM prompts. Compare outputs across models side by side, define tools for function calling, and iterate on prompts before deploying them. Navigate to **Agent Observability → Playground** in the sidebar (). :::info Beta The Playground is currently in Beta. ::: ## Prerequisites The Playground requires at least one [LLM Connection](/agent-observability/llm-connections) to be configured. If no connections exist, the model selector shows a link to **Settings → LLM Connections**. ## Getting Started 1. Select an **LLM Connection** and **Model** from the dropdowns at the top of the window 2. Add messages using the **+ Message** dropdown (User, Assistant, System, Developer, or Tool) 3. Type your prompt content in the message editor 4. Click **Submit** or press **⌘ Enter** to run The output appears at the bottom of the window with the model's response, including any tool calls or reasoning output. ## Multi-Window Comparison Click **New split window** to add up to four side-by-side windows. Each window has its own connection, model, messages, and output — useful for A/B testing prompts across different models or configurations. - **Duplicate window** — copies the window configuration - **Remove window** — removes a window (available when more than one is open) - **Run All** (⌘ Enter) — submits all windows that have a model selected, in parallel - **Reset** — replaces all windows with a single empty window ## Messages Messages support the following roles: | Role | Purpose | |------|---------| | **System** | System instructions for the model | | **User** | User input / prompt | | **Assistant** | Model response (for few-shot examples) | | **Developer** | Developer instructions | | **Tool** | Tool result linked to a tool call | Click the role badge on any message to cycle through roles. Drag the handle to reorder messages. ### Prompt References Type `@@@` in a message to link to a saved [prompt](/prompt-management). Oodle resolves prompt references before sending the request to the model. ## Template Variables Use `{{variable_name}}` syntax in message content to create template variables. The **Variables** panel (badge shows count) lets you fill in values for each variable before running. All variables must have values before submitting — the Playground warns if any are unfilled. ## Tools (Function Calling) Click **Tools** to define function tools the model can call. Each tool has: - **Name** — e.g. `get_weather` - **Description** — what the tool does - **JSON Schema** — parameter schema (visual builder or raw JSON) When the model responds with tool calls, they appear as expandable blocks in the output. :::caution Tools and structured output cannot be used at the same time. ::: ## Structured Output Click **Schema** to define a JSON schema that constrains the model's output format. Specify a name, description, and schema using the visual builder or raw JSON editor. ## Model Parameters Click the gear icon to configure optional parameters: | Parameter | Range | Default | |-----------|-------|---------| | **Temperature** | 0–2 | 1.0 | | **Max Tokens** | 1–65536 | 2048 | | **Top P** | 0–1 | 1.0 | | **Additional options** | Provider-specific JSON | `{}` | Each parameter has a toggle — enable it to override the model's default. ## Seeding from Traces From any trace detail page, click **Open in Playground** to load the trace's conversation into the Playground. This seeds: - All messages (system, user, assistant, tool) - The model name - Tool definitions (if present in the trace) Use this to replay and iterate on real production conversations. ### Replaying from one message To start from a single turn instead of the whole run, hover any message in the **Transcript** tab, or in the **Gen AI** panel of the trace waterfall, and click the pencil (**Edit and replay**). The Playground opens with the conversation up to and including that message, that message last and ready to edit. Run it to see what a different prompt would have produced from the same point. On a trace with sub-agents, the replay carries that agent's own run and not the whole trace. A sub-agent is handed a task rather than the conversation around it, so its run is what the call actually read. The Playground opens in a new tab and the conversation is handed to it once, so the tab's URL is not a shareable link to that conversation. Use **Open in Playground** when you want a link to send to somebody. ## Seeding from Prompts From any prompt detail page, click **Test in Playground** to load the prompt content into the Playground. Chat prompts load as multi-message conversations; text prompts load as a single system message. ## Save as Prompt Click **Save as Prompt** in the toolbar to save the current messages as a new prompt. This opens the prompt creation page with the messages pre-filled. ## Streaming The **Stream** checkbox (enabled by default) controls whether responses stream token by token or arrive as a complete response. Streaming shows a **Stop** button to end generation early. --- ## Signals A signal is a label on a span saying something happened in a conversation: the agent hit a rate limit, retried the same tool five times, refused a request, or the user gave up. Oodle detects most signals as your traces arrive. That costs nothing per span and runs on all of them, so a signal has a real rate you can chart, alert on and compare between releases, rather than a sampled estimate. Open **Agent Observability → Evaluators → Signals** () to see which signals your traffic carries. ## What a signal carries Every signal writes these span attributes: | Attribute | Meaning | |---|---| | `oodle.signal.execution` | The leaf type, such as `environment.rate_limited` | | `oodle.signal.category` | The family: `interaction`, `execution.failure`, `execution.loop`, `environment` or `agent` | | `oodle.signal.polarity` | `positive`, `negative` or `neutral` | | `oodle.signal.source` | Where it came from: detected at ingest, evaluated, or reported by the agent | | `oodle.signal.reason` | The pattern that matched | The same values are labels on the `oodle_genai_signal_total` metric, so a trace filter and a metric query give the same answer. Polarity is set when the signal is written, not looked up later. That matters for a signal like user satisfaction, where the direction comes from the sign of the value and no table could say it. ## Signals about the people using your agent Some signals read the conversation instead of the tool calls. They say what the person on the other side did, which is the part no error rate reaches: every span is green in all of them. | Signal | The user | |---|---| | `interaction.misalignment` | Said the reply was not what they asked for, or could not tell what it meant | | `interaction.stagnation` | Asked the same thing again without getting an answer | | `interaction.disengagement` | Broke off the conversation | | `interaction.refusal` | Was declined by the agent | Oodle reads these from the turns themselves, so they cost nothing per span and run on all of them. Each one carries `oodle.signal.category` of `interaction`, so one filter gives you the whole group. The daily Insights report lists them under **User interactions**, apart from the groups about your tools, because they are answered by a change to the prompt or the task rather than by fixing a service. ## Signals your agent reports about itself Some things only the agent knows. A missing credential looks like an ordinary clarifying question from the outside, and no external detector can tell the difference. Your agent reports one by setting a span attribute. There is no SDK to install: every framework already emits spans, so this is a convention rather than a dependency. ```python span.set_attribute("oodle.signal.agent", "missing_context") span.set_attribute( "oodle.signal.agent.detail", "no billing account on file for this customer", ) ``` `oodle.signal.agent` takes one of four categories: | Category | Use it when | |---|---| | `missing_context` | The agent lacks information or a credential it needs | | `repeatedly_broken_tool` | A tool keeps failing in a way the agent cannot work around | | `capability_gap` | The agent was asked for something it cannot do | | `complete_task_failure` | The agent finished without completing the task | `oodle.signal.agent.detail` is free text explaining the specific case, kept to 256 characters. It rides along as the signal's reason. Your own category names work too. An unrecognised name is kept and reported under the general `agent.reported` type, with the name you sent as the reason, so you can start with the four above and add your own as you learn what your agent runs into. Give the agent a tool that sets these attributes, and say in its prompt when to reach for it. The report then arrives on the same trace as the conversation it describes, next to the tool calls and the model responses that led to it. ## Signals from thumbs up and down Feedback you already send through `POST scores` appears alongside detected signals, marked as instrumented. It needs no extra work: it is the same score you were already writing, on the screen where people look for behaviour. ## Writing your own Describe a behaviour in plain English and Oodle drafts a classifier for it, shows you what it matched in your own traces, and turns it into a signal once you are happy. See **Agent Observability → Evaluators → Signals**. A new signal counts from the moment you turn it on. To find out what it would have caught before that, run a backfill from the **Backfills** tab: it reads past traffic and reports what the evaluator would have found, keeping those results separate from your live scores. --- ## Agents Oodle Agents allow you to securely connect your private infrastructure to Oodle. An agent runs inside your network (typically as a Kubernetes deployment) and establishes an outbound WebSocket connection to Oodle. This tunnel lets Oodle reach resources that are not publicly accessible, such as internal APIs, databases, and services behind a firewall. The agent is open source: [github.com/oodle-ai/oodle-agent](https://github.com/oodle-ai/oodle-agent). ## Use Cases | Use Case | Description | |----------|-------------| | **Private endpoint monitoring** | Run [synthetic checks](/synthetic-monitoring) against internal HTTP endpoints, DNS, TCP, Ping, SSL, and Traceroute targets that are not reachable from the public internet | | **Private database datasources** | Connect Grafana to Postgres databases inside your VPC without exposing them publicly | ## How It Works 1. You create an agent in the Oodle UI and receive a one-time registration token 2. You deploy the agent into your Kubernetes cluster using Helm 3. On first connection the agent uses the registration token to obtain a client certificate via mTLS 4. The agent opens a persistent WebSocket connection to Oodle 5. Oodle routes requests (synthetic checks, database queries) through the tunnel to your private infrastructure All traffic is encrypted in transit using mutual TLS (mTLS). The agent authenticates with a client certificate, and Oodle authenticates via its server certificate pinned to the Amazon CA. ## Creating an Agent :::note Creating an agent requires the **Admin** role. Users with the Editor or Viewer role cannot create or manage agents. ::: 1. Navigate to **Agents** in the sidebar 2. Click **Create Agent** 3. Enter a descriptive name (e.g. `production-cluster`, `staging-vpc`) 4. Click **Create** After creation, you will see two commands that must be run in order. **Save the registration token immediately** — it is single-use and expires in 24 hours. ### Step 1: Create the Kubernetes Secret The first command creates a namespace and a Kubernetes secret containing the agent's one-time registration token: ```bash kubectl get namespace oodle-monitoring 2>/dev/null || \ kubectl create namespace oodle-monitoring kubectl create secret generic oodle-agent- \ --namespace oodle-monitoring \ --from-literal=registration-token= ``` ### Step 2: Install the Agent via Helm The second command deploys the agent using the official [Helm chart](https://github.com/oodle-ai/oodle-agent): ```bash helm repo add oodle-ai https://oodle-ai.github.io/helm-charts helm repo update helm upgrade --install oodle-agent oodle-ai/oodle-agent \ --namespace oodle-monitoring \ --create-namespace \ --wait \ --set oodle.instance= \ --set oodle.agentId= \ --set oodle.agentName= \ --set existingSecret=oodle-agent- \ --set oodle.gatewayUrls=wss:///ws ``` :::tip Both commands are pre-filled with the correct values in the UI. You can copy them directly. ::: :::info On its first connection, the agent uses the registration token to submit a Certificate Signing Request (CSR) and receives a signed client certificate. All subsequent connections use mutual TLS (mTLS) — the registration token is automatically consumed and no longer needed. ::: ### Step 3: Restrict Allowed Endpoints (Optional) By default the agent can reach any endpoint in your network. To restrict which hosts the agent is allowed to connect to, add the `policy` values to the Helm install command: ```bash helm repo add oodle-ai https://oodle-ai.github.io/helm-charts helm repo update helm upgrade --install oodle-agent oodle-ai/oodle-agent \ --namespace oodle-monitoring \ # ... same flags as above ... --set policy.enable_access_restrictions=true \ --set "policy.allowed_endpoints[0]=my-postgres.internal:5432" \ --set "policy.allowed_endpoints[1]=*.prod.internal:443" \ --set "policy.allowed_endpoints[2]=10.0.1.50:8080" ``` Supported endpoint patterns: | Pattern | Description | |---------|-------------| | `host:port` | Exact match (e.g. `my-db.internal:5432`) | | `host` | Any port on that host | | `*.example.com:443` | Wildcard subdomain with specific port | | `*:5432` | Any host on a specific port | The policy is enforced for **all** protocols: HTTP, Postgres, TCP, Ping, DNS, SSL, and Traceroute. You can also restrict which Kubernetes namespaces and resources the agent can read: ```bash --set "policy.kubernetes.allowed_namespaces[0]=production" \ --set "policy.kubernetes.allowed_namespaces[1]=monitoring" \ --set "policy.kubernetes.deny_resources[0]=secrets" ``` To update the policy after installation, run `helm upgrade` with the new values, or edit the `oodle-agent-policy` ConfigMap directly and restart the agent pod. ## Viewing Agent Status The Agents list page displays all registered agents with: - **Name**: The agent name and its unique ID - **Status**: Current connection status (`ACTIVE` or `DISCONNECTED`) - **Created**: When the agent was registered - **Last Heartbeat**: The most recent heartbeat received from the agent Click on any agent row to open the detail drawer. ## Agent Detail Drawer ### Overview Tab Shows creation time, last heartbeat, capabilities, and an embedded health dashboard with real-time metrics for the agent. ### Datasources Tab Manage Postgres datasources that are routed through this agent. Each datasource creates a corresponding Grafana Postgres datasource that tunnels queries through the agent to a private database. #### Adding a Postgres Datasource 1. Open the agent detail drawer and go to the **Datasources** tab 2. Click **Add Datasource** 3. Fill in the connection details: - **Datasource Name**: A human-readable name - **Host**: The private Postgres host and port (e.g. `my-db.internal:5432`) - **Database**: The database name - **User / Password**: Postgres credentials - **SSL Mode**: `disable`, `require`, `verify-ca`, or `verify-full` - **TLS Certificates**: (Optional) CA cert, client cert, and client key for mTLS 4. Click **Create** Once created, a Grafana Postgres datasource is automatically provisioned. You can click **Explore** to query the database directly from Grafana, or use **Save & Test** to verify connectivity. ### Uninstall Tab Provides step-by-step commands to cleanly remove the agent: 1. **Uninstall the Helm release**: ```bash helm uninstall oodle-agent \ --namespace oodle-monitoring ``` 2. **Delete the Kubernetes secret**: ```bash kubectl delete secret oodle-agent- \ --namespace oodle-monitoring ``` 3. **Delete the agent from Oodle** using the **Delete Agent** button :::caution Deleting an agent disconnects all datasources and synthetic monitors routed through it. ::: ## Best Practices ### Naming Conventions Use names that identify the environment and cluster, e.g.: - `prod-us-east-1` - `staging-eks` - `dev-local` ### High Availability The agent maintains multiple connections for redundancy. If one connection becomes unavailable, traffic is automatically routed through another. ### Network Requirements The agent only requires **outbound** access on port 443 (WebSocket over TLS). No inbound firewall rules are needed. --- ## AI Canvas AI Canvas is a full-page, conversational AI assistant built into Oodle. It can query your metrics, logs, and traces, create Grafana dashboards and alerts, navigate the UI on your behalf, and answer questions about your infrastructure — all through natural language. ## Accessing AI Canvas There are two ways to interact with the AI assistant: ### Full-Page Canvas Hover over **Ask Nolly** in the sidebar, then select **AI Canvas** to open the dedicated full-page experience. You can also navigate directly to `/ai-canvas`. The full-page canvas includes a chat history sidebar on the left so you can revisit previous conversations. ### Sidebar Assistant Click **Ask Nolly** (or press Cmd + I) from any page to open the sidebar panel. The sidebar overlays the current page, giving you quick access to the AI while keeping your current context visible. ## Capabilities | Capability | Description | |------------|-------------| | **Metrics** | Find metrics, query with PromQL, and visualize results inline | | **Logs** | Search and summarize logs, identify error patterns | | **Traces** | Query traces and analyze latency or error patterns | | **Dashboards** | Create and update Grafana dashboards from a prompt | | **Alerts** | Create, update, and explain alert monitors | | **Anomalies** | Query detected anomalies across your infrastructure | | **Navigation** | Navigate to pages, adjust time ranges, and fill forms | | **Integrations** | Answer setup questions using Oodle documentation | ## Home Page When you open AI Canvas without an active conversation, the home page shows contextual information below the chat input to help you get started: - **Recent Dashboards** — your most-visited Grafana dashboards from the past week, sorted by frequency. If you have fewer than ten personal dashboards, the list is filled with popular dashboards across your organization. Click any dashboard to open it directly. - **Anomalies** — the top anomalies detected across your infrastructure in the last 24 hours, sorted by severity and recency. Click any anomaly to jump to the anomalies page with that item expanded. Both section headers link to their respective full pages (Dashboards and Anomalies). ## Using AI Canvas ### Starting a Conversation Type your question in the input box at the bottom and press Enter. You can also click one of the suggested prompts to get started. Example prompts: - "Create dashboard for high CPU usage by container" - "Search error logs for last 15 min" - "Create an alert for CPU throttling" - "Summarize logs for last 15 mins" - "How do I send k8s logs to Oodle?" ### Tool Calls and Inline Results The AI assistant uses tools to interact with your data. As it works, you will see expandable tool call cards showing what the assistant is doing — for example, finding metrics, running PromQL queries, or saving a dashboard. Query results such as charts and tables are rendered inline in the conversation. ### Auto-Approve Actions Some actions (like creating an alert or navigating to a page) require approval before executing. Enable the **Auto-approve actions** checkbox at the bottom of the chat to let the assistant execute these actions without prompting. ### Following Up After the assistant responds, you can ask follow-up questions in the same conversation. For example, after creating a dashboard you might ask the assistant to add more panels or adjust the time range. ## Chat History AI Canvas persists your conversations. The **Chat History** sidebar on the left lists all previous sessions with their title, age, and message count. Click any entry to reload that conversation and continue where you left off. Click **Start New Chat** to begin a fresh session. ## Example Workflows ### Create a Dashboard > "Create a health dashboard for the frontend service" The assistant will: 1. Search for relevant metrics in your environment 2. Run PromQL queries to verify the metrics return data 3. Build a Grafana dashboard with appropriate panels 4. Save the dashboard and provide a link to open it ### Debug an Alert > "Why is my CPU throttling alert firing?" The assistant will: 1. Look up the alert definition and current status 2. Query the underlying metrics to identify the source 3. Check logs and traces for correlated errors 4. Summarize findings and suggest next steps ### Search Logs > "Show me error logs from the payment service in the last hour" The assistant will: 1. Identify the appropriate log index 2. Run a filtered query for error-level logs 3. Summarize the top patterns and provide representative log entries --- ## Claude Claude ships with built-in OpenTelemetry support across all of its coding surfaces: the Claude Code CLI, the desktop app, and Cowork. Once enabled, Oodle collects metrics and event logs and turns them into use-case dashboards covering adoption, cost and ROI, engineering impact, tool behavior, and efficiency across your organization. ## Getting Started ### 1. Enable Telemetry The fastest way is to use the integration tile in the Oodle UI: 1. Navigate to **Settings → Integrations** 2. Open the **AI Observability** section 3. Click the **Claude Observability** tile 4. Select an API key and follow the steps shown The tile generates a ready-to-use configuration and lets you choose how to distribute it: to a single developer, to everyone working in a repository, or to your whole organization. Replace ``, ``, and `` in the snippets below with values from the integration tile. #### Individual For a single developer, export the variables directly in your shell before running `claude`: ```bash export CLAUDE_CODE_ENABLE_TELEMETRY=1 export OTEL_METRICS_EXPORTER=otlp export OTEL_LOGS_EXPORTER=otlp export OTEL_TRACES_EXPORTER=otlp export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf export OTEL_EXPORTER_OTLP_ENDPOINT=https:// export OTEL_EXPORTER_OTLP_HEADERS="X-API-KEY=, X-OODLE-INSTANCE=" export OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta export OTEL_LOG_USER_PROMPTS=1 export OTEL_LOG_TOOL_DETAILS=1 # Optional: resource attributes for team/repo breakdowns export OTEL_RESOURCE_ATTRIBUTES="git.repository.name=my-repo,org.team.name=platform,org.department=engineering,org.cost_center=eng-123" ``` :::tip For faster feedback during initial setup, lower the export intervals: ```bash export OTEL_METRIC_EXPORT_INTERVAL=10000 export OTEL_LOGS_EXPORT_INTERVAL=5000 ``` ::: #### Repository To share telemetry settings with everyone working in a repository, commit them to `.claude/settings.json` in the project. Claude Code applies them for anyone who works in the repo, with no per-developer setup required: ```json { "env": { "CLAUDE_CODE_ENABLE_TELEMETRY": "1", "OTEL_METRICS_EXPORTER": "otlp", "OTEL_LOGS_EXPORTER": "otlp", "OTEL_TRACES_EXPORTER": "otlp", "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1", "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", "OTEL_EXPORTER_OTLP_ENDPOINT": "https://", "OTEL_EXPORTER_OTLP_HEADERS": "X-API-KEY=, X-OODLE-INSTANCE=", "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE": "delta", "OTEL_LOG_USER_PROMPTS": "1", "OTEL_LOG_TOOL_DETAILS": "1", "OTEL_RESOURCE_ATTRIBUTES": "git.repository.name=my-repo,org.team.name=platform,org.department=engineering,org.cost_center=eng-123" } } ``` #### Server-managed (organization-wide) To distribute settings across your whole organization without device management (MDM), use the [Claude Code admin console](https://claude.ai/admin-settings/claude-code). Paste the same `env` configuration into **Admin Settings → Claude Code → Managed settings**. Clients fetch it when users authenticate and poll for updates hourly. - Requires a Claude for Teams or Enterprise plan, and the Owner or Primary Owner role to edit. - OpenTelemetry settings take effect after a full Claude Code restart. See the [server-managed settings documentation](https://code.claude.com/docs/en/server-managed-settings) for delivery, caching, and precedence details. ### 2. Verify Data Once telemetry starts flowing (metrics export every 60 s, events every 5 s by default), navigate to **AI Cost Management → Claude** in the Oodle sidebar. ## Dashboards The Claude analysis page is organized around the questions engineering leadership asks of a coding agent. Each tab embeds a dashboard that leads with headline numbers, followed by trends and ranked breakdowns. ### Usage and Adoption Is the team using it? | Panel | Description | |-------|-------------| | **Active Users** | Distinct engineers who ran Claude | | **Sessions** | Sessions started, plus sessions per active user | | **Active Time** | CLI processing time vs user interaction time | | **Surface / Model / Terminal Mix** | Where and how sessions run (CLI, desktop, Cowork; model share; editor terminals) | | **Version Adoption** | Activity by CLI version, to track upgrade velocity | | **Top Users** | Engineers ranked by sessions and active time | ### Cost and ROI What are we paying, and what do we get for it? | Panel | Description | |-------|-------------| | **Total Spend** | Estimated USD spend, the headline budget number | | **Cost per Active User** | Average spend per engineer, for seat-cost modeling | | **Cost per Commit / Cost per PR** | Unit economics of shipped output | | **Sessions Producing Outcomes** | Share of sessions that led to a commit or PR | | **Top Spenders** | Users and sessions ranked by spend | | **Spend Breakdown** | Spend by model, query source, agent, and skill | | **Team and Repository Breakdown** | Spend by `org.team.name` and `git.repository.name` resource attributes | ### Engineering Impact What did it produce? | Panel | Description | |-------|-------------| | **Lines Added / Removed** | Code throughput from accepted suggestions | | **Commits and Pull Requests** | Shipped output over time | | **Edit Accept Rate** | Share of suggested edits engineers accepted, with health thresholds | | **Avg Lines per Session** | Output volume per session | | **Output Leaderboards** | Top users by lines added, commits, and PRs | ### Tools and Automation Is the agent working well? | Panel | Description | |-------|-------------| | **Tool Calls** | Invocation volume, distinct tools, and per-tool trends | | **Tool Duration** | Average execution time by tool | | **Result Size** | Bytes returned per tool, the main driver of context growth and cost | | **Bash CLI Breakdown** | Specific commands run through Bash, by count and output size | | **MCP Servers and Tools** | External integration usage and latency | | **Skills** | Invocations of codified team workflows | ### Efficiency Is spend being used well? | Panel | Description | |-------|-------------| | **Cache Hit Rate** | Share of context served from cache, with health thresholds | | **Token Split** | Input vs cache read vs cache creation volume | | **Ghost Cost Leaderboard** | Sessions ranked by cache re-priming relative to new input | | **Cost per Line of Code** | Output per dollar, overall and by model | | **Prompt Length Analysis** | Prompt size distribution and cost per prompt | ## Sessions The Sessions tab shows individual Claude sessions: | Column | Description | |--------|-------------| | **Start Time** | When the session began | | **User** | Email of the developer | | **Model** | Primary model used | | **Prompts** | Number of user prompts | | **Duration** | Wall-clock duration | | **Cost** | Total estimated cost | | **Tools** | Number of tool calls | | **Tokens** | Total tokens (input + output) | | **Errors** | Count of errors | Click any row to open a **Session Detail** drawer showing a turn-by-turn timeline of every event. ### Session Detail Drawer The drawer displays: - **Session metadata**: user, model, app version, terminal type - **Aggregated stats**: total cost, tokens, tool calls, errors, duration - **Turn-by-turn timeline**: each turn is collapsible and shows the user prompt followed by individual events (API requests, tool calls, decisions). Every event row is expandable to reveal the full raw JSON payload. ## What Gets Collected ### Metrics Claude exports the following as OpenTelemetry metrics (delta temporality): | Metric | Key Labels | Description | |--------|------------|-------------| | `claude_code_token_usage` | `type`, `model`, `user_email` | Token count by type (input, output, cacheRead, cacheCreation) | | `claude_code_cost_usage` | `model`, `user_email` | Estimated cost in USD | | `claude_code_session_count` | `user_email` | Sessions started | | `claude_code_active_time_total` | `type` | User and CLI active time in seconds | | `claude_code_lines_of_code_count` | `type` | Lines added / removed | | `claude_code_commit_count` | `user_email` | Commits created | | `claude_code_pull_request_count` | `user_email` | Pull requests created | | `claude_code_code_edit_tool_decision` | `decision` | Edit accept / reject decisions | All metrics also carry `session_id`, `service_version`, `terminal_type`, and `job` (the surface: CLI, desktop app, or Cowork). Tool call counts, durations, and result sizes are derived from the `claude_code.tool_result` events below and power the Tools and Automation dashboard. ### Events (Logs) Events are exported via the OpenTelemetry logs protocol. Each event has a `log.attributes.event_type` field: | Event Type | Key Attributes | |------------|----------------| | `claude_code.user_prompt` | Prompt text (opt-in), character count | | `claude_code.api_request` | Model, cost, input/output tokens, cache tokens, duration | | `claude_code.tool_result` | Tool name, success, duration | | `claude_code.tool_use_decision` | Decision (accept/reject), source | Events are grouped by `session_id` to reconstruct the turn-by-turn timeline on the Sessions tab. ## Multi-team Organization Support Organizations with multiple teams or departments can add custom [resource attributes](https://code.claude.com/docs/en/monitoring-usage#multi-team-organization-support) to segment telemetry by repository, team, department, or cost center. ### Recommended Attributes | Attribute | Purpose | Example | |-----------|---------|---------| | `git.repository.name` | Per-repository breakdowns | `my-service` | | `org.team.name` | Team-level filtering | `platform` | | `org.department` | Department or business unit | `engineering` | | `org.cost_center` | Charge-back / cost allocation | `eng-123` | ### Configuration Set `OTEL_RESOURCE_ATTRIBUTES` as a comma-separated list of key=value pairs: ```bash export OTEL_RESOURCE_ATTRIBUTES="git.repository.name=my-repo,org.team.name=platform,org.department=engineering,org.cost_center=eng-123" ``` Or add it to your managed settings file (`.claude/settings.json`): ```json { "env": { "OTEL_RESOURCE_ATTRIBUTES": "git.repository.name=my-repo,org.team.name=platform,org.department=engineering,org.cost_center=eng-123" } } ``` :::warning Formatting requirements Values must **not** contain spaces. Use underscores, camelCase, or percent-encoding for special characters. See the [Claude Code docs](https://code.claude.com/docs/en/monitoring-usage#multi-team-organization-support) for the full specification. ::: Once set, these attributes appear on all metrics and events, allowing you to: - Filter dashboards by team or department - Track costs per cost center - Create per-repository usage reports - Set up team-specific alerts ## Further Reading - [Claude Code Monitoring Documentation](https://code.claude.com/docs/en/monitoring-usage) - [OpenTelemetry Integration](/integrations/logs/otel) - [Claude Code MCP Integration](/integrations/mcp/claude) --- ## OpenAI Codex OpenAI Codex has native OpenTelemetry support. Once enabled, Oodle collects metrics and event logs and turns them into use-case dashboards covering adoption, token consumption, tool behavior, and performance and reliability across your organization. ## Getting Started ### 1. Enable Telemetry The fastest way is to use the integration tile in the Oodle UI: 1. Navigate to **Settings → Integrations** 2. Open the **AI Observability** section 3. Click the **Codex Observability** tile 4. Select an API key and follow the steps shown Alternatively, add the following to your Codex configuration file (`~/.codex/config.toml`): ```toml #:schema https://developers.openai.com/codex/config-schema.json [otel] environment = "production" log_user_prompt = true exporter = { otlp-http = { endpoint = "https:///ingest/otel/v1/logs", protocol = "binary", headers = { "X-API-KEY" = "", "X-OODLE-INSTANCE" = "", }, }} metrics_exporter = { otlp-http = { endpoint = "https:///v2/otlp/metrics/", protocol = "binary", headers = { "X-API-KEY" = "", "X-OODLE-INSTANCE" = "", }, }} ``` Replace ``, ``, and `` with values from the integration tile. :::tip The integration tile in the Oodle UI generates a ready-to-copy `config.toml` with the correct endpoints and API key pre-filled. ::: #### Alternative: Environment Variables For quick testing you can export the standard OpenTelemetry variables directly in your shell: ```bash export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https:///v2/otlp/metrics/ export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https:///ingest/otel/v1/logs export OTEL_EXPORTER_OTLP_HEADERS="X-API-KEY=, X-OODLE-INSTANCE=" export OTEL_LOG_USER_PROMPTS=1 export OTEL_METRIC_EXPORT_INTERVAL=10000 export OTEL_LOGS_EXPORT_INTERVAL=5000 ``` ### 2. Roll Out to Your Team Commit the `config.toml` above to your dotfiles repository or place it in each developer's `~/.codex/` directory via your configuration management tool. ### 3. Verify Data Once telemetry starts flowing, navigate to **AI Cost Management → Codex** in the Oodle sidebar. ## Dashboards The Codex analysis page is organized around the questions engineering leadership asks of a coding agent. Each tab embeds a dashboard that leads with headline numbers, followed by trends and ranked breakdowns. ### Usage and Adoption Is the team using it? | Panel | Description | |-------|-------------| | **Active Users** | Distinct engineers who ran Codex | | **Conversations** | Distinct Codex conversations, plus conversations per active user | | **Prompts** | User prompts submitted | | **Activity Trends** | Active users and conversations over time | | **Surface / Model / Version Mix** | Where activity runs: the IDE/app backend vs the terminal CLI, model share, and version adoption | | **Top Users** | Engineers ranked by prompts and tokens | ### Token Consumption Token volume is the primary driver of Codex spend, so this tab is the budget view. | Panel | Description | |-------|-------------| | **Total Tokens** | Aggregate token usage, the headline consumption number | | **Tokens per Active User / per Conversation** | Unit consumption for capacity and seat modeling | | **Cached Input Rate** | Share of input context served from cache, with health thresholds. Higher is cheaper | | **Token Breakdown** | Usage by token type (input, output, cached, reasoning), model, surface, and version | | **Top Users and Sessions** | Highest-consumption engineers and conversations | | **Prompt Economics** | Average prompt size and total tokens consumed per prompt | Use the **Token Type** dashboard filter to isolate a category (input, output, cached_input, reasoning_output). ### Tools and Automation Is the agent working well? | Panel | Description | |-------|-------------| | **Tool Calls** | Invocation volume and distinct tools used | | **Tool Success Rate** | Share of tool calls that succeeded, with health thresholds | | **Avg Tool Duration** | Mean execution time, overall and per tool | | **Per-tool Trends and Leaderboards** | Calls, failures, and latency ranked by tool | | **MCP Servers** | External integration usage grouped by MCP server | ### Performance and Reliability Codex exports rich latency telemetry, so this tab answers how fast and stable the agent feels. | Panel | Description | |-------|-------------| | **Avg Time to First Token** | How long users wait before Codex starts responding, with health thresholds | | **Avg Turn Duration** | End-to-end latency per turn | | **WebSocket Success Rate** | API transport health, with health thresholds | | **Latency Trends** | Time to first token / first message and turn duration over time | | **Startup Latency** | Prewarm and shell snapshot times from launch to a usable session | | **Per-model Latency** | Time to first token and turn duration ranked by model | This tab is built on Codex's native metrics, which carry model, originator, and version dimensions; use its dashboard filters to slice by those. ## Sessions The Sessions tab shows individual Codex sessions: | Column | Description | |--------|-------------| | **Start Time** | When the session began | | **Session** | Codex conversation ID | | **User** | Email of the developer | | **Surface** | Where Codex ran: `app-server` (IDE/extension) or `cli` (terminal) | | **Duration** | Wall-clock duration | | **Tokens** | Total tokens (input + output + cached) | Click any row to open a **Session Detail** drawer showing a turn-by-turn timeline of every event. ### Session Detail Drawer The drawer displays: - **Session metadata**: user, model, app version - **Aggregated stats**: tokens, tool calls, errors, duration (only populated fields are shown) - **Turn-by-turn timeline**: each turn is collapsible and shows individual events (SSE events, WebSocket events, tool calls). Every event row is expandable to reveal the full raw JSON payload. ## What Gets Collected ### Metrics Codex exports the following as OpenTelemetry metrics (delta temporality): | Metric | Extra Labels | Description | |--------|--------------|-------------| | `codex_turn_token_usage` | `token_type` | Token count by type (input, output, cached, reasoning, total) | | `codex_thread_started` | none | Conversations started | | `codex_turn_tool_call` | none | Tool invocations per turn | | `codex_websocket_request` | `success` | WebSocket API requests | | `codex_turn_e2e_duration_ms` | none | End-to-end turn latency | | `codex_turn_ttft_duration_ms` | none | Time to first token | | `codex_turn_ttfm_duration_ms` | none | Time to first message | | `codex_websocket_event_duration_ms` | none | WebSocket event processing time | | `codex_startup_prewarm_duration_ms` | none | Startup prewarm latency | | `codex_shell_snapshot_duration_ms` | none | Shell snapshot capture latency | | `codex_turn_network_proxy` | none | Turns routed through the network proxy | All native metrics carry `model`, `originator`, and `app_version`. These power the Performance and Reliability dashboard. Oodle also derives session-level metrics from the event logs below: `oodle_logs_codex_token_usage` (tokens keyed by conversation, user, model, surface, version, and token type), `oodle_logs_codex_tool_count` and `oodle_logs_codex_tool_duration_ms` (per-tool activity with MCP server and success dimensions), and `oodle_logs_codex_prompt_length_bytes` (prompt sizes). These power the Usage and Adoption, Token Consumption, and Tools and Automation dashboards plus the Sessions tab, and they honor the sidebar User and Surface filters. ### Events (Logs) Events are exported via the OpenTelemetry logs protocol. Each event has an `attributes.event.name` field: | Event Type | Key Attributes | |------------|----------------| | `codex.conversation_starts` | Conversation ID, model, user email | | `codex.sse_event` | Model, event kind, token counts, duration | | `codex.websocket_event` | Model, event kind, duration, success | | `codex.websocket_request` | Model, duration, success | | `codex.websocket_connect` | Model, duration | | `codex.user_prompt` | Prompt text, prompt length | | `codex.tool_decision` | Tool name, decision, source | | `codex.tool_result` | Tool name, success, duration, error | Events are grouped by `conversation.id` to reconstruct the turn-by-turn timeline on the Sessions tab. ## Further Reading - [Codex Observability Documentation](https://developers.openai.com/codex/config-advanced#observability-and-telemetry) - [OpenTelemetry Integration](/integrations/logs/otel) --- ## Factory Droid Factory's Droid CLI has native OpenTelemetry support. Once enabled, Oodle collects metrics so you can track adoption, engineering impact, tool activity, and the repositories Droid works in across your organization. ## Getting Started ### 1. Enable Telemetry The fastest way is to use the integration tile in the Oodle UI: 1. Navigate to **Settings → Integrations** 2. Open the **AI Observability** section 3. Click the **Factory Droid** tile 4. Select an API key and follow the steps shown Alternatively, set two environment variables wherever Droid runs (shell profile, CI environment, or MDM-managed configuration): ```bash # Droid appends /v1/metrics to the endpoint export OTEL_TELEMETRY_ENDPOINT=https:// export OTEL_TELEMETRY_HEADERS="X-API-KEY=,X-OODLE-INSTANCE=" ``` Replace ``, ``, and `` with values from the integration tile. Metrics export every 60 seconds while sessions are active, and flow to both Factory and Oodle simultaneously. A failure to reach Oodle never affects Droid itself. ### 2. Roll Out to Your Team Add the two environment variables to your dotfiles repository, developer machine images, or configuration management tool. ### 3. Verify Data Once telemetry starts flowing, navigate to **AI Cost Management → Factory Droid** in the Oodle sidebar. ## Analysis Page The Factory Droid page is organized around the questions engineering leadership asks, with a tab per use case. ### Adoption Is Factory getting adopted, and by whom? | Panel | Description | |-------|-------------| | **Active Users / Sessions** | Distinct users and sessions with Droid activity | | **Logins and Slash Commands** | Engagement signals over the selected range | | **Activity by Model** | Which models drive Droid usage | | **Model Mix / Autonomy Mode / Environment** | How Droid is run: model split, autonomy levels, interactive vs other environments | | **Version Adoption** | How quickly the team upgrades the Droid CLI | | **Top Users by Activity** | The power users driving adoption, sorted and paginated | ### Engineering Impact What is Droid actually producing? | Panel | Description | |-------|-------------| | **Files Modified / Lines Modified** | Code change volume attributable to Droid | | **Commits and PRs Created** | How much of Droid's work ships | | **Files Read vs Files Modified** | Exploration vs change balance | | **By Extension** | Which parts of the stack Droid touches most | | **Per-User Leaderboards** | Lines, commits, and PRs by developer | ### Tools & Automation How does Droid work, and where does it struggle? | Panel | Description | |-------|-------------| | **Tool Invocations and Success Rate** | Overall tool call volume and health | | **Execution Time (p50 / p95)** | Tool latency trends and per-tool p95 | | **Tool Failures** | Which tools fail most | | **MCP Tools / Skills / Hooks** | Automation surface usage: MCP calls by server, skill invocations, hook activity | | **Installed Skills Inventory** | Which skills are installed, and where | ### Repo Insights Where across your codebase estate does Droid operate, and how healthy are those repos? Built from the `droid_repo_metadata` metric that Droid emits for every session: | Panel | Description | |-------|-------------| | **Repos Touched / Sessions per Repo** | Coverage of your repository estate | | **Repos with Tests / CI / Linters / Docs** | Guardrail coverage of the repos Droid works in | | **Primary Language Distribution** | Language mix of touched repos | | **Repo Profile** | Per-repo size, languages, contributors, and commit velocity | | **Repo Guardrails** | Test frameworks, CI/CD, linters, security, and observability tooling per repo | :::note Repositories are identified by a privacy-preserving hash of the remote URL, so repository names and URLs never leave the developer's machine. ::: ### Sessions The Sessions tab lists individual Droid sessions built entirely from metrics: | Column | Description | |--------|-------------| | **Start Time** | When the session began | | **Session** | Droid session ID | | **User** | Email of the developer | | **Duration** | Wall-clock duration | | **# Tools** | Tool invocations in the session | | **Files Read** | Files Droid read | | **Commits** | Commits created | | **# PRs** | Pull requests created | ## What Gets Collected Droid exports the following OpenTelemetry metrics with delta temporality. Every data point carries `user.email`, `user.id`, `session.id`, `model_id`, `organization.id`, and `service.version`, so all views can be filtered by user, model, and version. | Metric | Extra Labels | Description | |--------|--------------|-------------| | `droid.tool.invocations` | `tool_name`, `tool_category`, `tool_runtime`, `tool_succeeded`, `environment_type`, `session_autonomy_mode` | Tool invocations | | `droid.tool.execution_time` | same as invocations | Tool execution time histogram (ms) | | `droid.code.files_read` | `file_extension` | Files read | | `droid.code.files_modified` | `file_extension` | Files modified | | `droid.code.lines_modified` | | Lines of code modified | | `droid.git.commits` | | Commits created | | `droid.git.pull_requests` | | Pull requests created | | `droid.mcp.tool_invocations` | `mcp_server`, `tool_name` | MCP tool invocations | | `droid.skill.invocations` | `skill_name` | Skill invocations | | `droid.hook.invocations` | | Hook invocations | | `droid.slash_command.invocations` | `slash_command_name` | Slash command usage | | `droid.auth.login_success` | `auth_login_method` | Successful logins | | `droid.repo.metadata` | `repo_*` profile labels | Per-session repository profile snapshot (language, size, tests, CI, linters, contributors, commit velocity) | ## Further Reading - [Factory Telemetry Documentation](https://docs.factory.ai/enterprise/telemetry-export) - [OpenTelemetry Integration](/integrations/logs/otel) --- ## AI Cost Management Oodle's AI Cost Management gives engineering teams visibility into how AI coding agents are used across the organization. Track token usage, costs, session activity, tool calls, and more, all in one place. ## Why Monitor AI Agents? AI coding agents are powerful but opaque. Without observability you cannot answer questions like: - **How much are we spending** on AI-assisted coding? - **Which models** are being used, and by whom? - **How many sessions** run per day or week? - **Are tool calls succeeding**, or are agents hitting errors? - **What prompts** are developers sending to the agent? Oodle collects telemetry exported by AI agents via OpenTelemetry and surfaces it through a dedicated analysis page with pre-built dashboards and a session-level event timeline. ## Supported Agents | Agent | Metrics | Events / Logs | Status | |-------|---------|---------------|--------| | [**Claude**](./claude-code) | Token usage, cost, sessions, active time, lines of code, commits, PRs | User prompts, API requests, tool results, tool decisions | GA | | [**OpenAI Codex**](./codex) | Token usage, conversations, tool calls, WebSocket requests, performance timings | SSE events, WebSocket events, conversation starts, tool decisions, tool results | GA | | [**Factory Droid**](./factory) | Tool invocations, files read/modified, lines of code, commits, PRs, skills, repo profiles | Metrics only | GA | ## Getting Started 1. Navigate to **Settings → Integrations** 2. Open the **AI Observability** section 3. Click the tile for the agent you want to set up 4. Select an API key and follow the steps shown See the agent-specific pages for detailed setup instructions: - [Claude Setup](./claude-code#getting-started) - [OpenAI Codex Setup](./codex#getting-started) - [Factory Droid Setup](./factory#getting-started) ## Analysis Page The AI Agent Analysis page is available in the sidebar under **AI Cost Management**. It has a set of dashboard tabs tailored to each agent, plus a **Sessions** tab. Use the **agent dropdown** at the top of the sidebar to switch between Claude, Codex, and Factory Droid. ### Dashboard Tabs Each agent gets pre-built dashboards organized around the questions leadership asks: adoption, cost and ROI, engineering impact, and tool activity. For example, Claude has Usage and Adoption, Cost and ROI, Engineering Impact, Tools and Automation, and Efficiency tabs; see each agent's page for its tab set. ### Sessions Tab Shows a table of individual sessions with key stats. Click any row to open a **Session Detail** drawer showing a turn-by-turn timeline of every event. ### Sidebar Filters A sidebar on the left lets you filter data by: - **Time Range**: Grafana-style time range picker - **Event Type**: filter events by type (sessions tab only) - **Status**: OK or HasError (sessions tab only) - **Model**: filter by LLM model - **User**: filter by developer email - **App Version**: filter by agent version - **Terminal**: filter by terminal type ## Best Practices - **Start with a single user** to verify telemetry is flowing before rolling out org-wide. - **Enable prompt logging** to get full prompt text in the event timeline, which is useful for understanding usage patterns. - **Set up alerts** on cost or token metrics to catch unexpected spending spikes. - **Use sidebar filters** to drill down by user or model when investigating specific sessions. --- ## Skills & Runbooks Skills and runbooks add domain expertise to Oodle's AI assistant. They capture your team's debugging workflows, investigation playbooks, and operational procedures so the AI can follow them automatically. When you ask a question in [AI Canvas](/ai-canvas) or through [MCP](/integrations/mcp), Oodle detects relevant skills and applies them to guide its investigation. You can also attach skills directly to [monitors](/alerts/monitors) so the AI follows a specific runbook when that alert fires. {/* */} ## Concepts ### Skills Skills are structured investigation workflows. Each skill has a **name**, a **description** that tells the AI when to use it, and a set of **steps** written in natural language - similar to an `AGENTS.md` file. The AI follows these steps when the skill is triggered. ### Runbooks Runbooks are attached to specific monitors and guide the AI when that alert fires. Unlike skills, runbooks are **created automatically** - Oodle learns from how your team investigates incidents and generates a runbook to handle future instances of the same alert. You can review, edit, or disable auto-generated runbooks at any time. ## Creating a Skill 1. In the sidebar, go to **Ask Nolly > Settings** and open the **Skills** tab. 2. Click **+ New Skill** in the top-right corner. 3. Fill in the following fields: | Field | Description | |-------|-------------| | **Name** | A short identifier for the skill (up to 100 characters). | | **Description** | Tells the AI when to use this skill. Be specific - for example, *"Use when the user asks whether an issue affects one customer or multiple customers and wants cross-cluster comparison."* | | **Steps** | The investigation procedure the AI should follow. Supports Markdown. Use the **Write**, **Preview**, and **Upload file** tabs to author the content. | 4. Click **Save**. ### Writing Effective Steps Write steps the way you would brief a new on-call engineer. Include: - What to check first and in what order. - Which metrics, logs, or traces to query. - How to interpret the results. - What output format to produce (verdict, comparison table, confidence level). **Example - API query service fan-out investigation:** > **Name:** `api-query-fanout-investigation` > > **Description:** When investigating api-server query errors, > look for massive fan-out across shards and HTTP 400 error > responses returning unexpected content. ```markdown When api-server query errors spike, investigate the query-worker container logs. 1. Look for 'StatusCode: 0, canceled, context canceled' warnings - this indicates a massive fan-out across shards (e.g., data-partition-{0-9}) where queries are being canceled due to timeouts. 2. Check for 'StatusCode: 400' errors returning HTML/XML instead of expected JSON. This means an upstream dependency is returning an error page, causing deserialization failures in the query path. 3. Verify if the failing queries are missing required parameters like 'start' or 'end' timestamps. Missing parameters cause the upstream to reject the request with a 400. 4. If fan-out is confirmed, check whether the query scope is too broad (too many shards) and whether adding tighter label filters would reduce the blast radius. ``` ## Attaching Skills to Monitors You can attach a skill or runbook to a monitor so the AI references it when investigating that alert: 1. Open the monitor you want to configure. 2. In the monitor settings, select a skill under the **Runbook** field. 3. Save the monitor. When this monitor fires and you ask the AI about it, the attached runbook is loaded automatically. ## Skill Sources Skills can come from two sources, visible in the **Source** filter on the Skills & Runbooks page: | Source | Description | |--------|-------------| | **User** | Created manually by your team. | | **Auto-learned** | Generated by Oodle based on observed patterns and past investigations. You can review, edit, or disable these. | ## Enabling and Disabling Skills Each skill has an **Enabled** toggle. Disabled skills are not loaded by the AI during conversations. Use this to temporarily remove a skill without deleting it. ## Using Skills ### In AI Canvas Skills are loaded on demand. When you ask a question in AI Canvas, the AI matches your query against skill descriptions and applies relevant skills automatically. You can also reference a skill by name in your prompt. ### Via MCP Skills are exposed through the Oodle MCP server. Any AI agent connected to Oodle's MCP - such as [Claude Code](/integrations/mcp/claude) or [Cursor](/integrations/mcp/cursor) - can discover and invoke skills during a conversation. ### With Alerts When you ask the AI about a firing alert that has an attached runbook, the AI follows the runbook steps to investigate and report findings. ## Managing Skills From the Skills & Runbooks page you can: - **Search** skills using the search bar. - **Filter** by type (Skill / Runbook), source (Auto-learned / User), or status (Enabled). - **Edit** a skill by clicking on it to open the edit dialog. - **Clone** an existing skill to create a variation. - **Delete** a skill from the edit dialog. --- ## Alerting on Logs and Traces Oodle [monitors](/alerts/monitors) evaluate PromQL. To alert on logs or on traces, you make the signal a metric, then you write a monitor on that metric. | Signal | Metric | Setup | |--------|--------|-------| | Log lines, structured or plain text | `oodle_logs_` from a [LogMetrics](/log-management/log-metrics) rule | Create a rule. | | Spans of all services | `oodle_trace_metrics` | None. Oodle makes it from every span. | | GenAI spans (model calls, tools, tokens, cost) | `oodle_genai_*` | None. Oodle makes it from GenAI spans. | The labels of the metric become the labels of the alert. A label that a LogMetrics regex takes from a log line, for example a user name, shows in the alert and in the notification. ## Alert on Logs 1. **Create a LogMetrics rule.** The rule selects the log lines and emits a metric. It can take labels and numbers from any field, and from plain-text lines with a regular expression. See [LogMetrics](/log-management/log-metrics). 2. **Create a monitor on the metric.** Use `increase()` or `rate()` on counters, and `histogram_quantile()` on histograms. Aggregate with `sum by (...)` and keep the labels that you want in the alert. A rule applies to logs that arrive after you save it. The monitor can fire after the metric has data for the monitor time window. ### Example: Alert When a User Has Too Many Failed Logins Log lines: ```text WARN auth Login failed for user alice@example.com: bad password ``` **Step 1: LogMetrics rule.** Save this rule as `login-failures.json` and run `oodle log-metrics create -f login-failures.json`, or create it in **Logs** > **LogMetrics**: ```json { "name": "login-failures-by-user", "filter": { "all": [ { "field": "message", "operator": "contains", "value": "Login failed for user" } ] }, "labels": [ { "name": "user", "valueExtractor": { "field": "message", "regex": "Login failed for user (\\S+?):" } } ], "metricDefinitions": [ { "name": "login_failures", "type": "log_count" } ] } ``` The rule emits the counter `oodle_logs_login_failures` with a `user` label. **Step 2: Monitor.** | Field | Value | |-------|-------| | Name | `Too many failed logins for {{ $labels.user }}` | | Query | `sum by (user) (increase(oodle_logs_login_failures[10m]))` | | Condition | Critical: **Above** `5` | | Message | `{{ $labels.user }} had {{ $value }} failed logins in 10 minutes.` | | Grouping | **By Labels**, with `user`, to get one notification for each user | The same monitor in Terraform: ```hcl resource "oodle_monitor" "login_failures" { name = "Too many failed logins for {{ $labels.user }}" promql_query = "sum by (user) (increase(oodle_logs_login_failures[10m]))" conditions = { critical = { value = 5 operation = ">" for = "1m" } } } ``` See the [`oodle_monitor` resource](https://registry.terraform.io/providers/oodle-ai/oodle/latest/docs/resources/monitor) for all the fields. ### More Log Alert Queries Each query uses the metric of a LogMetrics rule with the name in the query. The `slow_tool_calls` and `tool_latency_ms` rules are examples on the [LogMetrics](/log-management/log-metrics) page. Error lines for each service, above 50 in 5 minutes: ```promql sum by (service) (increase(oodle_logs_error_lines[5m])) > 50 ``` p95 tool latency from plain-text `in ms` lines, above 5 seconds: ```promql histogram_quantile( 0.95, sum by (tool, le) (rate(oodle_logs_tool_latency_ms_bucket[5m])) ) > 5000 ``` Tool calls of 10 s or more, from a `log_count` rule with a threshold regex in its filter: ```promql sum by (tool) (increase(oodle_logs_slow_tool_calls[15m])) > 0 ``` No log lines from a job in the last 30 minutes (the job stopped): ```promql (sum(increase(oodle_logs_backup_completed[30m])) or vector(0)) < 1 ``` When no line matches, the metric has no series and `sum` returns no value, so the comparison has nothing to compare. `or vector(0)` gives the value 0 in that case, and the monitor fires. ## Alert on Traces Oodle makes [span metrics](/traces/span-metrics) from all spans. You can write monitors on them without setup. Error ratio of a service above 5%: ```promql sum by (service_name) (increase(oodle_trace_metrics{service_name="checkout", span_status="Error"}[5m])) / sum by (service_name) (increase(oodle_trace_metrics{service_name="checkout"}[5m])) > 0.05 ``` More than 20 requests of 1 s or more in 5 minutes, for each operation: ```promql sum by (service_name, span_name) ( increase(oodle_trace_metrics{ service_name="checkout", is_first_span_of_service="true", duration_ns_bucket=~"1000000000|2000000000|4000000000|8000000000|16000000000|32000000000|64000000000|128000000000" }[5m]) ) > 20 ``` `duration_ns_bucket` is the lower edge of a power-of-two bucket in nanoseconds. See [Duration Buckets](/traces/span-metrics#duration-buckets) for the edges. ### GenAI Alerts GenAI tool calls slower than 8 s, for each tool: ```promql ( sum by (tool_name) (increase(oodle_genai_tool_latency_seconds{le="+Inf"}[15m])) - sum by (tool_name) (increase(oodle_genai_tool_latency_seconds{le="8"}[15m])) ) > 0 ``` Tool error ratio above 10%: ```promql sum by (tool_name) (increase(oodle_genai_tool_error_total[15m])) / sum by (tool_name) (increase(oodle_genai_tool_call_total[15m])) > 0.1 ``` Model call p95 latency above 30 s: ```promql histogram_quantile( 0.95, sum by (model, le) (rate(oodle_genai_llm_call_latency_seconds[10m])) ) > 30 ``` LLM cost above 50 US dollars in one hour: ```promql sum(increase(oodle_genai_generation_cost_dollars[1h])) > 50 ``` ## Choose the Right Tool | Question | Use | |----------|-----| | Tell me when a log pattern occurs, now and in the future. | LogMetrics rule and a monitor. | | Tell me when a service has errors or slow requests. | Monitor on `oodle_trace_metrics`. | | Tell me when agent tools fail or are slow, or when LLM cost grows. | Monitor on `oodle_genai_*`. | | How many matching log lines were there last week? | Log search aggregation. | | How many spans matched a filter last week? | TraceQL metrics query. | | Which traces match this filter? | TraceQL search or the [Trace Explorer](/traces/trace-explorer). | ## Answer Questions About the Past LogMetrics rules do not backfill. Use log search and TraceQL to answer questions about data that Oodle already stored, and to test a filter before you make a rule from it. ### Log Search Aggregations The [Logs Query API](/integrations/logs/query), the log explorer and the Oodle MCP server accept OpenSearch Query DSL with these aggregations: `terms`, `date_histogram`, `range`, `filters`, `avg`, `min`, `max`, `sum`, `percentiles`, `percentile_ranks` and `cardinality`. Failed logins for each user in one week, from a structured `user` field. The time range is in epoch milliseconds: ```json {"index": ""} {"size": 0, "query": {"bool": {"filter": [{"match_phrase": {"message": "Login failed"}}, {"range": {"timestamp": {"gte": 1759190400000, "lte": 1759795200000, "format": "epoch_millis"}}}]}}, "aggs": {"by_user": {"terms": {"field": "user.keyword", "size": 20}}}} ``` Error lines in each hour: ```json {"index": ""} {"size": 0, "query": {"bool": {"filter": [{"match_phrase": {"level.keyword": "ERROR"}}, {"range": {"timestamp": {"gte": 1759190400000, "lte": 1759795200000, "format": "epoch_millis"}}}]}}, "aggs": {"per_hour": {"date_histogram": {"field": "timestamp", "fixed_interval": "1h"}}}} ``` Aggregations group and compute on log fields. When the value is inside the text of a message, for example `in 1250 ms`, make it a field first: - A [log transform](/log-management/log-transforms) extracts the value into a field when Oodle ingests the log. Aggregations can then use the field. - A [LogMetrics](/log-management/log-metrics) rule extracts the value into a metric with a regex. Use it for alerts and for long-term trends. To count past lines that contain a text pattern, filter on the text with `match_phrase` and count with `date_histogram` or the hit total. ### TraceQL [TraceQL](/traces/traceql) searches the stored spans and computes metrics from them, with any span attribute. For example, the number of GenAI tool calls over 10 s for each tool: ```traceql { name =~ "execute_tool.*" && duration > 10s } | count_over_time() by (span.gen_ai.tool.name) ``` Run TraceQL in Grafana Explore, with the HTTP API, with `oodle traces traceql`, or with the `query_traceql` MCP tool. --- ## Terraform for alerts Configure all your alerts, notifiers and notification policies through terraform. ## Advantages of managing alerts as code * They are version-controlled, making changes and rollbacks more transparent and trackable. * This approach allows you to standardize alert configurations across environments, eliminating manual errors and drift between staging and production. You can apply the same alert configuration across all your deployments / environments. * Terraform's declarative nature also makes it easy to spin up or tear down alerts alongside your infrastructure, ensuring your monitoring evolves with your stack. * Integrating alert management into your CI/CD pipeline means faster, more reliable deployments, as your alerts will always align with the infrastructure they monitor. ## How :::info Use oodle's [Terraform Provider](https://registry.terraform.io/providers/oodle-ai/oodle/latest/docs) ::: ### Prerequisites To setup Oodle Alerts via Terraform files, you'll need the following values: - `OODLE_INSTANCE`: Your Oodle instance ID. Go to `Settings` icon -> `API Keys` page in your Oodle UI to find out. (Oodle UI links: ) - `OODLE_API_KEY`: Your Oodle API key for authentication. Go to `Settings` icon -> `API Keys` in your Oodle UI to choose an appropriate key. (Oodle UI links: ) ### Example usage ```hcl terraform { required_providers { oodle = { source = "registry.terraform.io/oodle-ai/oodle" } } } # These can also be set as environment variables: # export OODLE_DEPLOYMENT=https://us1.oodle.ai/ # export OODLE_INSTANCE="my-instance" # export OODLE_API_KEY="my-api-key" provider "oodle" { deployment_url = "https://us1.oodle.ai/" instance = "my-instance" api_key = "my-api-key" } # Example usage of notifier, notification policy and monitor. # Refer to https://registry.terraform.io/providers/oodle-ai/oodle/latest/docs/resources/notifier # for schema documentation. resource "oodle_notifier" "notifier_test1" { name = "terraform_test_notifier" type = "pagerduty" pagerduty_config = { service_key = "foo" send_resolved = true } } # Refer to https://registry.terraform.io/providers/oodle-ai/oodle/latest/docs/resources/notification_policy # for schema documentation. resource "oodle_notification_policy" "test1" { name = "terraform_test_policy" notifiers = { critical = [oodle_notifier.notifier_test1.id] } } # Refer to https://registry.terraform.io/providers/oodle-ai/oodle/latest/docs/resources/monitor # for schema documentation. resource "oodle_monitor" "test1" { name = "terraform_test" promql_query = "sum(rate(oober_food_delivery_revenue_usd[3m]))" conditions = { critical = { value = 1210000 operation = ">" for = "3m" alert_on_no_data = true } } notification_policy_id = oodle_notification_policy.test1.id } ``` Apply terraform configuration ```bash terraform init terraform apply ``` --- ## Alerts export const FilteredDocCardList = () => { const category = useCurrentSidebarCategory(); const filteredItems = category.items.filter(item => item.label !== 'Alerts'); return ; }; ## Entities Oodle's alerting system allows you to monitor your metrics and receive notifications when certain conditions are met. There are three entities that make up the alerting system: - Monitors - Notification Policies - Notifiers Additionally, you can create composite monitors that combine multiple monitor conditions. Alerts functionality is available in the Oodle UI under the Bell icon in the sidebar navigation. ## Monitors Monitors are the core of the alerting system. They define: - A PromQL query to evaluate - Conditions for triggering alerts (`Warning` and `Critical` thresholds) - [Notification policy](#notification-policies) to be used for routing alerts - Labels allow specifying a set of additional labels to be attached to the alert. - Annotations specifies a set of informational labels that can be used to store longer additional information such as alert descriptions or runbook links. Labels and annotations can be templated in a similar manner as documented in [Prometheus alertmanager](https://prometheus.io/docs/prometheus/latest/configuration/template_examples/). Monitors are periodically evaluated and alerts are triggered when threshold conditions are met. See the [Monitors documentation](./monitors.mdx) for detailed configuration instructions. ## Notifiers [Notifiers](./notifiers.mdx) are the endpoints where alerts are sent. Oodle supports several types of notifiers: - [Slack](./notifiers.mdx#slack) - [PagerDuty](./notifiers.mdx#pagerduty) - [Opsgenie](./notifiers.mdx#opsgenie) - Webhook - [Google Chat](./notifiers.mdx#google-chat) - [Microsoft Teams](./notifiers.mdx#microsoft-teams) See the [Notifiers documentation](./notifiers.mdx) for detailed configuration instructions for each notifier type. ## Notification Policies Notification policies determine how alerts are routed to different notifiers. Policies allow you to: - Define separate notifiers for `Warning` and `Critical` alerts - Create global policies that apply to all monitors in addition to the specific policies attached to the monitors. See the [Notification Policies documentation](./notification-policies.mdx) for detailed configuration instructions. ## Composite Monitors Composite monitors allow you to create monitors based on combinations of multiple monitors. Using the `ALERTS` metric, you can implement complex logic such as: - Alert only when multiple conditions are met simultaneously (AND logic) - Alert when any of several conditions are met (OR logic) - Alert when a threshold number of monitors are firing For example, you can create a composite monitor that triggers only when both a database monitor and an API service monitor are firing, helping you identify dependent service failures. See the [Composite Monitors documentation](./composite-monitors.mdx) for detailed configuration instructions and examples. --- ## Composite Monitors Composite monitors allow you to create monitors based on combinations of other monitors. This is useful when you need to alert on complex conditions that depend on multiple monitors. ## What are Composite Monitors? Composite monitors use the `ALERTS` metric, which is automatically generated by Oodle for all active alerts. By querying this metric, you can create monitors that trigger based on the state of other monitors. ## Creating a Composite Monitor To create a composite monitor, you'll use the `ALERTS` metric in your PromQL query. Each alert has several labels that you can filter on: - `_oodle_monitor_id`: The unique ID of the monitor that generated the alert - `alertstate`: The current state of the alert (`firing` or `pending`) - `alertname`: The name of the alert - `_oodle_severity`: The severity level (`critical`, `warn` or `no_data`) ### Basic Example: AND Logic To alert when **both** Monitor A and Monitor B are firing: ```promql ( count(ALERTS{ _oodle_monitor_id="", alertstate="firing" }) ) and ( count(ALERTS{ _oodle_monitor_id="", alertstate="firing" }) ) ``` This composite monitor will only trigger when both specified monitors are actively firing. Set the condition to trigger when the result is greater than 0, indicating both alerts are firing. ### OR Logic Example To alert when **either** Monitor A or Monitor B is firing: ```promql count(ALERTS{ _oodle_monitor_id=~"|", alertstate="firing" }) ``` ### Severity-Based Composite Monitor To alert when any critical alert from a specific set of monitors is firing: ```promql count(ALERTS{ _oodle_monitor_id=~"monitor-id-1|monitor-id-2", alertstate="firing", _oodle_severity="critical" }) ``` ## Finding Monitor IDs To create composite monitors, you'll need the monitor IDs of the monitors you want to combine. You can find a monitor's ID: 1. Navigate to the monitor in the Oodle UI 2. The monitor ID is displayed in the URL params 3. You can also use labels to identify monitors instead of IDs --- ## Migrations Oodle supports importing alerts from various platforms to make your migration seamless. Choose the platform you're migrating from: ## Alertmanager / Prometheus Import your Prometheus alert rules and Alertmanager notification routes into Oodle. **Methods available:** - Upload `prometheus.yaml` and `alertmanager.yaml` configuration files directly - Connect to running Prometheus/Alertmanager instances and scrape alerts 👉 [Import Alertmanager Alerts](/integrations/metrics/prometheus/alertmanager-alerts) ## Grafana Import your existing Grafana Dashboards, Alerts, and Datasources into Oodle automatically using a Service Account Token. **What gets imported:** - Dashboards with panels, queries, and folder structure - Alert rules configured in Grafana - Datasource configurations 👉 [Import from Grafana](/integrations/metrics/grafana) --- ## Monitors Monitors allow you to define metric queries that trigger alerts based on specific conditions. The system periodically evaluates these queries and generates alerts when the results violate the defined conditions. ## Key Components of a Monitor ### Alert Name A descriptive name that identifies the monitor. You can use template variables in the name using [Prometheus templates](https://prometheus.io/docs/prometheus/latest/configuration/template_reference/) syntax. ### Query A PromQL query that the system evaluates periodically. You can use any valid PromQL query syntax. To alert on logs, create a [LogMetrics](/log-management/log-metrics) rule and use its `oodle_logs_*` metric in the query. To alert on spans, use the built-in [span metrics](/traces/span-metrics) `oodle_trace_metrics` and `oodle_genai_*`. See [Alerting on Logs and Traces](/alerts/alerting-on-logs-and-traces) for full examples. ### Conditions Conditions specify the threshold values that trigger alerts. There are two severity levels: - `Critical` - `Warning` You must define at least one condition, and you can set different thresholds for each severity level. The following operators are available for conditions: - Equal to - Not equal to - Above - Above or Equal to - Below - Below or Equal to ### Message A customizable message that accompanies alert notifications. You can enhance messages using template variables with [Prometheus templates](https://prometheus.io/docs/prometheus/latest/configuration/template_reference/) syntax. For example, with a query like `max by (job) (up)`, you can access the job name in your message using the template variable `{{ $labels.job }}`. :::note Oodle notifications automatically include the following information, so you don't need to add them to your message: - Alert name - Severity - Threshold - Metric Value (the current value when the alert fires) - Labels and Annotations ::: ### Notification Policy Notification policies determine where alert notifications are sent. You can configure multiple notifiers as destinations for your alerts. Each rule specifies which notifiers to trigger and can optionally be scoped by label conditions and severity. #### How Routing Rules Work Notification rules are evaluated **top to bottom** on a first-match basis. The first rule whose conditions match the alert fires its notifiers; subsequent rules are skipped. The last rule without any label conditions acts as the **fallback** and catches everything that didn't match an earlier rule. Rules follow an **If / Else If / Else** pattern: | Position | Prefix | Description | |----------|--------|-------------| | First rule (with conditions) | **If** | Evaluated first | | Additional rules (with conditions) | **Else If** | Evaluated only when preceding rules don't match | | Last rule (no conditions) | **Always** / **Else** | Fallback — matches all remaining alerts | You can drag and drop rules to reorder them. #### Label-Based Routing Each rule can include one or more **label conditions** to match alerts. Conditions within a single rule are combined with AND logic. Available operators are: - `is` — exact match - `is not` — negated match - `matches regex` — regex match - `does not match regex` — negated regex match Label values are auto-suggested from the monitor's query results, so you can pick from labels that actually appear in your data. **Example — Team-based routing:** Route alerts to the owning team's channel based on a `team` label: | Rule | Condition | Notifier | |------|-----------|----------| | **If** | `team` is `backend` | Slack `#backend-alerts` | | **Else If** | `team` is `frontend` | Slack `#frontend-alerts` | | **Else** | *(none — fallback)* | Slack `#ops-alerts` | **Example — Environment-based routing:** Escalate production alerts to your incident management tool while keeping non-production alerts in Slack: | Rule | Condition | Notifier | |------|-----------|----------| | **If** | `environment` is `production` | OpsGenie | | **Else** | *(none — fallback)* | Slack `#dev-alerts` | **Example — Combined conditions:** Conditions within a rule are AND'd together, so you can be very specific: | Rule | Conditions | Notifier | |------|------------|----------| | **If** | `team` is `payments` AND `environment` is `production` | PagerDuty | | **Else If** | `environment` is `production` | OpsGenie | | **Else** | *(none — fallback)* | Slack `#alerts` | #### Severity-Based Routing Within each rule, you can filter by alert severity using the **for** dropdown. The options are: | Option | Matches | |--------|---------| | **all** | Critical, Warning, and No Data alerts | | **critical** | Only Critical alerts | | **warning** | Only Warning alerts | | **no data** | Only No Data alerts | When you select a specific severity, you can add additional severity rows within the same rule to send different severities to different notifiers. For example, within a single rule you can send `critical` alerts to PagerDuty and `warning` alerts to Slack. #### Notification Policies vs. Direct Notifiers Each rule supports two action types, selectable via the dropdown next to the rule: - **notify on** — Send directly to one or more notifiers (Slack, OpsGenie, PagerDuty, etc.) - **trigger policy** — Delegate to a reusable [Notification Policy](/alerts/notification-policies) that defines its own routing, grouping, and timing ### Grouping Grouping controls how notifications are consolidated. You can choose from three options: - By Monitor: Receive a single notification per monitor (default behavior) - By Labels: Receive a notification for each unique set of specified labels - Disabled: Receive a notification for each individual timeseries For example, consider a monitor with this query that alerts when the `up` metric for any `job` is 0: ``` max by (job, instance) (up{}) ``` Here's how different grouping options affect notifications: - **By Monitor:** You receive one notification that includes all affected jobs and instances - **By Labels** (with `job` specified): You receive one notification per affected job - **Disabled:** You receive one notification per affected timeseries (job-instance combination) ### Notification Timings These parameters control the timing of grouped notifications: #### Group Wait Group Wait defines how long to wait before sending the first notification for a new group of alerts. The default is 30 seconds. #### Group Interval Group Interval defines how long to wait before sending a notification for new alerts added to a group that has already sent notifications. The default is 5 minutes. #### Repeat Interval The repeat interval specifies how often notifications are resent for active alerts. For example, if you have an active alert for the timeseries `up` with `job=foo` and `instance=bar`, setting a repeat interval of 10 minutes means the notification will be sent every 10 minutes until the alert is resolved. :::info For example, with the `up` metric monitor grouped "By Monitor", consider a group wait of 30 seconds and a group interval of 5 minutes: If two jobs go down within the first 30 seconds, you'll receive one notification covering both jobs. If a third job goes down a minute later, its notification will be delayed by the 5-minute group interval. ::: ### Labels Labels add metadata to alerts. You can use [Prometheus templates](https://prometheus.io/docs/prometheus/latest/configuration/template_reference/) syntax to create dynamic labels. A common use case is tagging monitors with owner teams to identify responsible teams for each monitor. ### Annotations Annotations provide additional context for alerts, such as descriptions or runbook links. Like labels, annotations support [Prometheus templates](https://prometheus.io/docs/prometheus/latest/configuration/template_reference/) syntax. ## Creating a Monitor with AI The quickest way to create a monitor is to ask the AI Assistant. Open the sidebar assistant (Cmd + I) or [AI Canvas](/ai-canvas) and describe the alert you need: - `Create an alert to notify me when container memory usage is above 90%` - `Alert me when the error rate on api-server exceeds 5%` - `Create an alert for container restarts in the last 10 minutes` The AI will build the PromQL query, set the threshold, and save the monitor. You can review and adjust it from the alerts page afterwards. ## Creating a Monitor Manually 1. Navigate to **Alerts** (Bell icon in sidebar) 2. Click **New Alert** 3. Configure the following settings: - **Name:** Enter a descriptive name for your monitor - **Query:** Specify the PromQL query to evaluate - **Conditions:** Define the alerting conditions - **Message:** (Optional) Add a message for alert notifications - **Notification Policy:** (Optional) Select routing rules for alerts - **Grouping:** (Optional) Configure how you want to group alerts. Additionally, you can configure various timing parameters by clicking on the **Clock** icon. - **Labels:** (Optional) Add metadata labels to the alerts - **Annotations:** (Optional) Include additional contextual information 4. Click **Save** to create the monitor --- ## Muting ## Overview You can mute alerts to temporarily silence notifications for specific alerts without deleting or disabling them permanently. This is useful during maintenance windows, known incidents, or when you need to reduce noise. ## When to Use Mute alerts when: - Performing scheduled maintenance - Investigating a known issue - Testing changes in production - Dealing with expected high traffic events - Reducing alert fatigue during incident response ## How to Mute an Alert 1. Go to the Alerts page. 2. Find the alert you want to mute. 3. Click on alert's context menu [•••] and select 'Mute'. 4. Select the duration for muting (options: 1 hour, 6 hours, 12 hours, 1 day, 2 days, infinite, or custom). 5. Optionally add labels for muting. 6. Click 'Add Muting Rule'. 7. To unmute, click on the red trash can. ## How to Unmute an Alert 1. Navigate to the Alerts page. 2. Filter or find muted alerts using the Muted filter in the left sidebar, or sort the 'Muted left' column. 3. Click on a muted alert's Muted indicator in the Muted left column. 4. Click on the red trash can to permanently remove it. ## Muted Alert Behavior - Muted alerts do not trigger notifications via email, Slack, PagerDuty, or other channels - Muted alerts still evaluate conditions, and change state to "firing" - You can view muted alert firing history in the alert timeline - Muted alerts appear with a 'Muted' badge in the alerts list - The system automatically unmutes alerts when the mute period expires ## FAQ ### Can I mute multiple alerts at once? Yes, you can select multiple alerts from the alerts list and mute in-bulk using the bulk actions menu. ### What happens if an alert triggers while muted? The alert condition is still evaluated, the state of the alert is appropriately updated, but no notifications are sent. You can see these occurrences in the alert history. ### Can I schedule a mute in advance? Yes, when selecting 'Custom' duration, you can set a future start time for the mute period. ### Are there limits on mute duration? No, there are no maximum limits on mute duration. ## Related Features - [Alert Conditions](/alerts/monitors#conditions) - Modify alert thresholds to reduce false positives - [Notification Policies](/alerts/notification-policies) - Configure which channels receive alert notifications - [Notifiers](/alerts/notifiers) - Configure notification channels --- ## Notification Policies Notification policies let you route alerts to different notifiers based on severity levels. Each policy can be configured with specific notifiers for both `Warning` and `Critical` severity levels. With notification policies, you can: - Create team-specific policies to route team alerts to designated notifiers - Set up deployment-specific policies to route alerts from different deployments (e.g. dev, staging, production) to appropriate notifiers ## Create a Notification Policy 1. Navigate to **Alerts** (Bell icon in sidebar) → click the ellipsis (**...**) → select **Notification Policies** 2. Click the **+** icon. 3. Fill in the following fields: - Name: A descriptive name for your notification policy - Critical: (Optional) Select notifiers to receive alerts with `Critical` severity - Warning: (Optional) Select notifiers to receive alerts with `Warning` severity - Global: Enable this toggle to apply the policy to all monitors in addition to their individual notification policies. This is particularly useful when migrating from another system and you want to verify Oodle alerts while maintaining your existing alert system. By default, this toggle is **OFF**. 4. Click **Save** to create the notification policy :::note You can also create notification policies directly from the monitor create/edit page by clicking the **Add Notification** button. ::: ## Use a Notification Policy After creating a notification policy, you can attach it to any monitor during creation or editing. Once attached, the monitor's alerts will be automatically routed to the notifiers specified in the policy. --- ## Notifiers Notifiers are endpoints that receive alert notifications when monitor conditions are triggered. This guide explains each supported notifier type and provides step-by-step configuration instructions. ## Supported Notifiers ### Slack To send notifications to Slack, you'll need an incoming webhook URL. There are two methods to create one: 1. Slack Apps 2. Incoming Webhooks (Legacy) #### Slack Apps Follow these steps to create an incoming webhook using Slack Apps. For more details, see the official [Slack documentation on incoming webhooks](https://api.slack.com/messaging/webhooks). 1. Create a Slack App: - Visit [https://api.slack.com/apps?new_app=1](https://api.slack.com/apps?new_app=1) - Choose a name for your app - Select your workspace - Click **Create App** 2. Enable incoming webhooks: - Open the [Apps management dashboard](https://api.slack.com/apps) - Select your app - Click on Incoming Webhooks - Toggle **Activate Incoming Webhooks** to ON 3. Create a webhook: - Click **Add New Webhook to Workspace** - Select the channel for receiving messages - Click **Authorize** You'll receive a webhook URL that looks like this: ``` https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX ``` :::note Webhooks created through Slack Apps can only post to their designated channel. You'll need separate webhooks for different channels. ::: #### Incoming WebHooks App (Legacy) The legacy Incoming WebHooks app allows you to create webhooks that can send messages to any channel in your workspace. 1. Install the [Incoming WebHooks](https://slack.com/marketplace/A0F7XDUAZ-incoming-webhooks) app to your workspace 2. Click **Add to Slack**, select a default channel, then click **Add Incoming WebHooks Integration** :::note Legacy webhooks can send messages to any channel in your workspace, unlike Slack Apps webhooks which are channel-specific. ::: #### Configuration To set up a Slack notifier in Oodle: 1. Navigate to **Alerts** (Bell icon in sidebar) → click the ellipsis (**...**) → select **Notifiers** 2. Click the **+** icon and choose **Slack** from the dropdown 3. Fill in the following: - Name: A descriptive name for your notifier - API URL: The URL from Slack - Channel (optional): Override the default channel. Note: This only works with legacy webhooks :::note For Slack Apps webhooks, messages will always go to the channel specified during webhook creation, regardless of the channel setting in Oodle. ::: 4. Click **Save** to create the notifier ### Pagerduty To send notifications to Pagerduty, you'll need an API Key. Oodle supports following Pagerduty Integrations: 1. Events API V2 (Recommended) 2. Prometheus ##### Events API V2 (Recommended) [Events API V2](https://developer.pagerduty.com/docs/events-api-v2-overview) is rich Events API in comparison to Prometheus integration. For example, it allows to set Pagerduty Alert's Severity, attach images etc. which are not possible in Prometheus integration type. To get an Events API v2 API Key, follow: 1. Log in to PagerDuty and navigate to **Services** in the top menu bar. 2. Click on the service you are currently using, or create a new service. 3. Click **Integrations** and then **Add another integration**. 4. Select **Events API V2** from the list of integrations. 5. After creating the integration, you will be able to see the API Key for this integration. ##### Prometheus Prometheus Integration is less richer in comparison to Events API V2 Integration. To get a Prometheus Integration API Key, follow: 1. Log in to PagerDuty and navigate to **Services** in the top menu bar. 2. Click on the service you are currently using, or create a new service. 3. Click **Integrations** and then **Add another integration**. 4. Select **Prometheus** from the list of integrations. 5. After creating the integration, you will be able to see the API Key for this integration. #### Configuration To set up a Pagerduty notifier in Oodle: 1. Navigate to **Alerts** (Bell icon in sidebar) → click the ellipsis (**...**) → select **Notifiers** 2. Click the **+** icon and choose **Pagerduty** from the dropdown 3. Fill in: - Name: A descriptive name for your notifier - Integration: Choose `Events API V2` or `Prometheus` based on the Integration you have configured on Pagerduty. - API Key: Your Pagerduty integration API key 4. Click **Save** to create the notifier When using `Events API V2`, Oodle sets [Pagerduty Alert's Severity](https://support.pagerduty.com/main/docs/dynamic-notifications#severity-and-urgency-mapping) based on the Condition Severity defined within the monitor: - Critical condition in Oodle is mapped to `critical` severity in Pagerduty. - Warning condition in Oodle is mapped to `warning` severity in Pagerduty. Follow [Configure Dynamic notifications](https://support.pagerduty.com/main/docs/dynamic-notifications#configure-dynamic-notifications) in Pagerduty to govern alert notifications based on Severity. ### Opsgenie To send notifications to Opsgenie, you'll need an API key. Here's how to get one: 1. Log in to Opsgenie and navigate to [Integrations](https://app.opsgenie.com/settings/integration/integration-list) 2. Click **Add Integration** and select **Prometheus** 3. Configure the integration: - Enter a name - Select the team to receive alerts - Click **Continue** 4. Click **Turn on Integration** to activate it 5. [Optional] In `Incoming | Rules for creating and processing alerts` section, Click on `Create Alert` and update `Message` field from `[Prometheus]: {{message}}` to `{{message}}`. #### Configuration To set up an Opsgenie notifier in Oodle: 1. Navigate to **Alerts** (Bell icon in sidebar) → click the ellipsis (**...**) → select **Notifiers** 2. Click the **+** icon and choose **Opsgenie** from the dropdown 3. Fill in: - Name: A descriptive name for your notifier - API Key: Your Opsgenie integration API key 4. Click **Save** to create the notifier Oodle sets [Opsgenie's Priority](https://support.atlassian.com/opsgenie/docs/what-is-the-priority-level-of-integration/) based on the Condition Severity defined within the monitor: - Critical condition in Oodle is mapped to `P1` priority in Opsgenie. - Warning condition in Oodle is mapped to `P2` priority in Opsgenie. ### Rootly To send notifications to Rootly, you'll need a Bearer Token Secret from a Rootly Alertmanager source. 1. In Rootly, navigate to [Alert Sources > New Alertmanager Source](https://rootly.com/account/alert-sources/new?alerts_source%5Bsourceable_type%5D=Alerts%3A%3AAlertmanagerSource) 2. Give the source a name (e.g. "Oodle Alerts") 3. Under **Urgency**, add a condition: `$.alerts[0].labels._oodle_severity` is `warn` sets urgency to **Medium**, otherwise **High** 4. Save and copy the **Bearer Token Secret** #### Configuration To set up a Rootly notifier in Oodle: 1. Navigate to **Alerts** (Bell icon in sidebar) → click the ellipsis (**...**) → select **Notifiers** 2. Click the **+** icon and choose **Rootly** from the dropdown 3. Fill in: - Name: A descriptive name for your notifier - Bearer Token: The Bearer Token Secret from Rootly 4. Click **Save** to create the notifier Oodle delivers alerts in Alertmanager webhook format. Rootly uses the `_oodle_severity` label to map alert urgency based on the conditions you configured above. A Rootly notifier is a webhook underneath, so it also accepts a [Custom Payload](#custom-payload), but with a difference. Rootly's alert source parses the standard Alertmanager body, and the urgency condition above reads `$.alerts[0].labels._oodle_severity` out of it, so Oodle keeps that body in place: anything you add is sent *alongside* the standard fields rather than replacing them. The editor names those fields below the box, and expands to show the full body that goes out. Oodle adds the standard fields as it delivers the alert, thus you write only your own keys, and the API and the Terraform provider behave the same way. A payload that sets one of the standard keys itself is rejected, because the value would be lost. Two of the standard fields cannot be carried on a custom payload: `groupKey` is dropped, and `version` is sent as the number `4` rather than the string `"4"`. For more details, see the [Rootly Alertmanager docs](https://docs.rootly.com/integrations/alertmanager). ### Google Chat To send notifications to Google Chat, you'll need a webhook URL. Get the Webhook URL by following [Create a webhook for the Google Space](https://developers.google.com/workspace/chat/quickstart/webhooks) for the Google Space you want to send notification to. When creating the webhook in Google Chat, we recommend: - **Name**: `Oodle Alerts` - **Avatar URL**: `https://www.oodle.ai/img/logo_square.png` This gives alert messages a recognizable name and Oodle logo in Google Chat. #### Configuration To set up a Google Chat notifier in Oodle: 1. Navigate to **Alerts** (Bell icon in sidebar) → click the ellipsis (**...**) → select **Notifiers** 2. Click the **+** icon and choose **Google Chat** from the dropdown 3. Fill in: - Name: A descriptive name for your notifier - Webhook URL: The Webhook URL you created above. 4. Click **Save** to create the notifier. ### Microsoft Teams To send notifications to Microsoft Teams, you'll need a webhook URL. Get the Webhook URL by following [Create an incoming Webhook with Workflows](https://support.microsoft.com/en-gb/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) for the Teams Channel you want to send notifications to. #### Configuration To set up a Microsoft Teams notifier in Oodle: 1. Navigate to **Alerts** (Bell icon in sidebar) → click the ellipsis (**...**) → select **Notifiers** 2. Click the **+** icon and choose **Microsoft Teams** from the dropdown 3. Fill in: - Name: A descriptive name for your notifier - Webhook URL: The Webhook URL you created above. 4. Click **Save** to create the notifier ### Webhook A webhook notifier posts each alert to an HTTP endpoint you control. By default Oodle sends the standard [Alertmanager webhook payload](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config). #### Configuration To set up a webhook notifier in Oodle: 1. Navigate to **Alerts** (Bell icon in sidebar) → click the ellipsis (**...**) → select **Notifiers** 2. Click the **+** icon and choose **Webhook** from the dropdown 3. Fill in: - Name: A descriptive name for your notifier - API URL: The endpoint Oodle posts to - Authorization Credentials (optional): Sent as a `Bearer` token in the `Authorization` header - Custom Payload (optional): See below 4. Click **Save** to create the notifier #### Custom Payload If your endpoint expects a different request body, set **Custom Payload** to a JSON object. It replaces the default body entirely. Every string in the object (keys included, at any nesting depth) is a [Go template](https://pkg.go.dev/text/template) rendered against the alert notification data, so you can shape the request to whatever the endpoint needs: ```json { "text": "{{ .CommonLabels.alertname }} is {{ .Status }}", "severity": "{{ .CommonLabels._oodle_severity }}", "labels": "{{ .CommonLabels | toJson }}", "sections": [{ "summary": "{{ .CommonAnnotations.summary }}" }] } ``` A rendered string that itself parses as JSON is inlined as structured data rather than quoted, which is what makes `toJson` useful: `labels` above arrives at the endpoint as an object, not as a string containing JSON. The payload is per notifier: each webhook notifier carries its own, and one left empty keeps the default body. The template data is the same [Alertmanager notification data](https://prometheus.io/docs/alerting/latest/notifications/) available to the other notifier types. Expand **Available Variables** above the editor for the full list: `.Status`, `.CommonLabels.alertname`, `.CommonLabels._oodle_severity`, `.CommonAnnotations._oodle_message`, `.Alerts.Firing` and the rest. :::warning Oodle does not validate that the rendered payload is what your endpoint expects. If the template is malformed or the endpoint rejects the body, the notification fails to deliver. Use **Test** on the notifier after saving. ::: ## Use a Notifier Once you have a notifier created, you can use it in a notification policy to route alerts to the notifier. See the [Notification Policies documentation](./notification-policies.mdx) for more details. --- ## Anomaly Alerting Anomaly Alerting lets you configure notification policies for anomalies detected by Oodle's anomaly detection system. When anomalies are detected in your logs or metrics, alerts are sent to the notification channels you configure. ## Getting Started Navigate to **Anomalies → Anomaly Alerting** in the sidebar. The page shows the available anomaly detection modules and their status. ## Detectors Table The page displays a table of all anomaly detectors: | Column | Description | |--------|-------------| | **Name** | Detector name (underscores are displayed as spaces). | | **Description** | What the detector monitors. | | **Data Sets** | The data source the detector operates on (e.g., `logs`, `metrics`), shown as badges. | | **Status** | Toggle switch to enable or disable the detector. | Clicking an enabled detector row opens a drawer that shows the alerts generated by that detector, using the same interface as the [Alerts](/alerts/) page. ## Notification Policy At the top of the page, the **Notification Policy** label shows the currently assigned policy. Click it to open a drawer where you can create or select an existing notification policy. Notification policies define which channels (Slack, email, PagerDuty, etc.) receive anomaly alerts. ### Setting a Notification Policy 1. Click the **Notification Policy** value (or the "-" if none is set). 2. In the drawer, select an existing policy or create a new one. 3. Click **Save and Add Notification Policy**. You can remove the current policy by clicking the delete icon next to the policy name. ## Enabling / Disabling Detectors Toggle the **Status** switch for each detector: - **Enabled** (blue) — The detector actively monitors for anomalies and sends alerts through the configured notification policy. The row becomes clickable to view related alerts. - **Disabled** (grey) — The detector is paused and does not generate alerts. The row is not clickable. :::note Toggling a detector requires edit permissions. Users without edit access will see disabled switches. ::: ## How It Works 1. Oodle continuously analyzes incoming logs and metrics using machine learning models. 2. When a pattern deviates significantly from the learned baseline, an anomaly is detected. 3. If the corresponding module is enabled and a notification policy is configured, an alert is sent. 4. Detected anomalies are also visible in the [Anomaly Detection](/anomaly-detection/) dashboard. ## Best Practices - **Enable both modules** for comprehensive coverage across logs and metrics. - **Set up a dedicated notification channel** for anomaly alerts to avoid mixing them with other alert types. - **Review anomalies regularly** on the Anomaly Detection dashboard, even if alerts are configured — not all anomalies may be severe enough to trigger notifications. - **Tune notification policies** to avoid alert fatigue. Start broad and narrow down as you learn which anomalies are actionable. ## Related Pages - [Anomaly Detection](/anomaly-detection/) — View and investigate detected anomalies. - [Alerts](/alerts/) — Configure alert rules and notification policies. --- ## Anomaly Detection Oodle automatically identifies unusual patterns in your metrics and logs, helping you catch issues before they impact your users. ![Anomalies List](/img/anomalies-list.png) ## Overview Anomaly detection helps you: - **Detect Issues Early**: Automatically identify unusual patterns before they become critical problems - **Reduce Alert Noise**: Focus on genuine anomalies by blocking repetitive or irrelevant patterns - **Compare Historical Patterns**: Understand how current behavior differs from your historical baselines - **Prioritize Issues**: Anomalies are ranked by severity to help you focus on what matters most ## How It Works Oodle continuously monitors your infrastructure and applications, automatically detecting: - **Metric Anomalies**: Unusual behavior in your infrastructure and application metrics - **Log Anomalies**: New or unusual patterns in your error logs When anomalies are detected: - They appear on the Anomalies page for you to review - You can set up alerts to be notified immediately - You can block repetitive or expected patterns to reduce noise ## Types of Anomalies ### Metric Anomalies Oodle detects unusual patterns in your infrastructure and application metrics, including: - **CPU Throttle**: Services experiencing CPU throttling - **High Node CPU**: Nodes with unusually high CPU usage - **Service Communication Errors**: Communication issues between services - **API Errors**: High error rates in your APIs - **Container Restarts**: Containers restarting unexpectedly - **High Memory**: Services with excessive memory usage - **Pod Phase Issues**: Pods in problematic states (e.g., CrashLoopBackOff) ### Log Anomalies Oodle identifies unusual patterns in your logs by detecting: - **New Patterns**: Error messages that haven't appeared before - **Spikes**: Sudden increases in existing error patterns - **High Volume**: Recurring errors with unusually high occurrence rates Each anomaly shows whether it's a new issue or a recurring problem, helping you prioritize your response. ## Using the Anomalies Page The Anomalies page (`/anomalies`) provides three main tabs: ### Anomalies Tab View all detected anomalies with comprehensive filtering and details: - **Filters**: Filter by type (metrics/logs), subtype, cluster, namespace, or service - **Time Range**: Select the time window to view anomalies - **Details**: Click on any anomaly to see: - Trend charts showing the anomaly over time - Related logs (for log anomalies) - Metric queries (for metric anomalies) - Labels and metadata ![Anomalies List](/img/anomalies-list.png) ### Detectors Tab Control which types of anomalies to detect: - Toggle individual detector types on/off - Changes save automatically - Useful for temporarily disabling noisy or irrelevant detectors ![Anomaly Detectors](/img/anomalies-detectors.png) ### Spam Tab Manage blocked anomalies to reduce noise: - **Search**: Search through blocked anomalies to find specific items - **View Blocked Items**: See all anomalies you've blocked - **Unblock**: Remove items from the blocklist to allow them to appear again ![Spam Management](/img/anomalies-spam.png) When you block an anomaly, it won't trigger alerts or appear in your anomaly feed, keeping your view focused on what matters. ## FAQ ### How does Oodle detect log anomalies? Oodle doesn't evaluate individual log lines. Instead it works at the **pattern** level: 1. **Filter to errors** — only error-level logs are considered. Non-error logs are never evaluated. 2. **Group by pattern** — error logs are bucketed by **cluster**, **container**, and **fingerprint** (a pattern identifier for structurally similar messages). 3. **Compare against 24 hours ago** — the total count for each group in the last 1-hour window is compared against the same 1-hour window 24 hours ago. 4. **Apply thresholds** — a group is flagged when: - It has **more than 4 occurrences** in the current hour, **and** - It meets one of these conditions: - **New Pattern** — the group didn't exist in the baseline window - **Spike** — the count has at least doubled compared to 24 hours ago - **Volume** — a recurring error with notable count that hasn't doubled ![Log anomaly detection flow](/img/anomaly-log-detection.svg) ### How does Oodle detect metric anomalies? Oodle runs predefined queries against your infrastructure and application metrics every 5 minutes: 1. **Query current and historical values** — each query produces a single value for the last 1-hour window and a comparison value from the same window 24 hours ago. 2. **Compare values** — an anomaly is flagged when: - The current value shows a **significant percentage increase** over the historical value (used for CPU throttling, node CPU, memory usage, API error rates, and service communication errors), or - The current value is **non-zero** (used for container restarts and pod phase issues, where any occurrence is notable). ![Metric anomaly detection flow](/img/anomaly-metric-detection.svg) ### What time window is used to detect anomalies? Both log and metric anomaly detection compare two one-hour windows: - **Current window**: the last hour (`now − 1h` to `now`) - **Baseline window**: the same hour one day ago (`now − 25h` to `now − 24h`) Using the same time-of-day accounts for diurnal traffic patterns. The checks run every 5 minutes, so anomalies are re-evaluated with fresh data on each cycle. ### How long after a fix before an anomaly disappears? Anomalies are not stored as persistent records — they are re-computed from live data every 5 minutes. Once a fix is deployed: 1. The one-hour sliding window needs to clear of pre-fix data. In the worst case this takes **up to one hour** after the fix. 2. On the next 5-minute check after the window clears, no new data point is emitted for that anomaly. 3. The anomaly's trend chart in the UI drops to zero and it effectively disappears from the Anomalies page. This means it can take **up to 1 hour** after a fix is deployed for the anomaly to fully disappear. ![Anomaly resolution timeline](/img/anomaly-resolution.svg) ### Can anomaly detection be customized? Yes — Oodle's anomaly detection is customizable. Reach out to us to tailor detection rules based on your specific data patterns and operational needs. ## Best Practices 1. **Review Regularly**: Check the Anomalies page regularly to catch emerging issues early 2. **Block Noise**: Use the blocking feature to suppress known non-issues or expected patterns 3. **Create Targeted Alerts**: Set up alerts for specific anomaly types that are most critical to your operations 4. **Investigate Recurring Anomalies**: Pay attention to recurring anomalies, as they may indicate persistent underlying issues 5. **Use Filters**: Filter by cluster, namespace, and service to focus on specific areas of your infrastructure --- ## Dashboards Dashboards are a way to query, visualize, and organize your observability data to gain deeper insights at a glance. Oodle provides dashboards for both metrics and logs. ## Metrics Dashboards Build dashboards using PromQL to visualize your metrics data. Create panels with time series graphs, gauges, stat panels, and more. - **Query with PromQL** - Use the query builder or write queries directly - **Dashboard variables** - Create dynamic, reusable dashboards - **Infrastructure as code** - Manage dashboards via Terraform [Explore Metrics Dashboards →](/dashboards/metrics) ## Logs Dashboards Create dashboards from your log data to monitor application health, track errors, and analyze patterns. Build visualizations and combine them into comprehensive monitoring views. - **Multiple chart types** - Line, bar, area, pie, heat map, and more - **Aggregations** - Count, sum, average, and unique count metrics - **Flexible grouping** - Date histograms, terms, filters, and ranges [Explore Logs Dashboards →](/dashboards/logs/log-dashboards) ## Explore Further --- ## Dashboards(Logs) Dashboards combine multiple visualizations into a single view, giving you a comprehensive overview of your log data. Build dashboards to monitor application health, track errors, and analyze user behavior. :::tip Try it Live Explore a working example dashboard in the [Oodle Playground](https://play.oodle.ai/logs/app/dashboards) to see a multi-panel dashboard. ::: ## Terminology | Term | Description | |------|-------------| | **Dashboard** | A collection of panels arranged on a canvas that provides a unified view of your data | | **Panel** | A single visualization displayed on a dashboard (also called a visualization) | | **Visualization** | A chart, graph, metric, or table that represents your log data visually | | **Index Pattern** | The data source that defines which logs are available for visualizations | ## Creating a New Dashboard ![Create Dashboard](/img/osd-create-dashboard.png) 1. Navigate to the **Dashboards** section from the sidebar 2. Click **Create Dashboard** 3. You'll see an empty dashboard canvas ready for panels ## Adding Visualizations ### Add Existing Visualization 1. Click **Add** in the toolbar 2. Select **Add from library** 3. Browse or search for saved visualizations 4. Click on a visualization to add it to the dashboard ### Create New Visualization 1. Click **Add** in the toolbar 2. Select **Create new** 3. Choose a visualization type 4. Configure and save the visualization 5. It will automatically be added to your dashboard ## Dashboard Controls ### Time Filter The time picker in the top navigation controls the time range for all panels. You can: - Select predefined ranges (Last 15 minutes, Last 24 hours, etc.) - Set custom absolute time ranges - Use relative time expressions ### Query Bar Enter a query in the search bar to filter data across all panels: ``` container_name:frontend AND log_level:error ``` ### Filters Click **Add filter** to create field-based filters that apply to all panels. Filters appear as pills below the query bar and can be: - Toggled on/off - Pinned across dashboards - Inverted (exclude matching documents) ### Refresh - **Manual refresh**: Click the refresh button - **Auto-refresh**: Set an interval (e.g., every 30 seconds) for live monitoring ## Panel Options Click the gear icon on any panel to access: | Option | Description | |--------|-------------| | **Edit visualization** | Modify the underlying visualization | | **Clone panel** | Duplicate the panel on this dashboard | | **Create drilldown** | Link to another dashboard or URL | | **Full screen** | Expand the panel to full screen | | **Replace panel** | Swap with a different visualization | | **Delete from dashboard** | Remove the panel | ## Saving Dashboards 1. Click **Save** in the toolbar 2. Enter a dashboard title 3. Optionally add a description 4. Choose whether to save the current time filter with the dashboard 5. Click **Save** ### Save Options - **Save as new dashboard**: Creates a copy with a new name - **Store time with dashboard**: Opens dashboard with the saved time range instead of the default ## Next Steps - Learn about [creating visualizations](/dashboards/logs/visualizations) to build custom panels for your dashboards - Explore the [logs interface](/logs-visualization) for ad-hoc log analysis --- ## Visualizations Visualizations are the building blocks of [dashboards](/dashboards/logs/log-dashboards). They transform your log data into charts, graphs, and other visual formats to identify trends, track metrics, and analyze patterns. You can create visualizations directly from the **Visualize** section, or create them while building a dashboard by selecting **Create new** when adding a panel. :::tip Try it Live Explore working examples in the [Oodle Playground](https://play.oodle.ai/logs/app/visualize) to see visualizations in action. ::: ## Getting Started To create a new visualization: 1. Navigate to the **Visualize** section from the sidebar 2. Click **Create visualization** 3. Select a visualization type 4. Choose your index pattern (your log data source) 5. Configure metrics and buckets 6. Save your visualization ## Core Concepts ### Metrics Metrics determine **what** you're measuring. Common metric types include: | Metric | Description | Example Use Case | |--------|-------------|------------------| | **Count** | Number of documents matching the query | Total logs per minute | | **Sum** | Total of a numeric field | Total bytes transferred | | **Average** | Mean value of a numeric field | Average response time | | **Min/Max** | Minimum or maximum value | Peak memory usage | | **Unique Count** | Distinct values of a field | Number of unique users | ### Buckets Buckets determine **how** your data is grouped. Common bucket types include: | Bucket | Description | Example Use Case | |--------|-------------|------------------| | **Date Histogram** | Groups data by time intervals | Logs per hour | | **Terms** | Groups by field values | Logs per service | | **Filters** | Groups by custom queries | Error vs Success logs | | **Range** | Groups numeric values into ranges | Response time buckets | | **Histogram** | Groups by numeric intervals | Request size distribution | ## Visualization Editor The visualization editor lets you build charts by configuring filters, time ranges, and aggregations. ![Visualization Editor](/img/osd-visualization-explanation.png) | # | Component | Description | |---|-----------|-------------| | 1 | **Filter** | Filter logs to include only data relevant to your visualization. Add multiple filters to narrow down results. | | 2 | **Time Range** | Set the time window for your data. Use quick selections like "Last 1 day" or define custom ranges. | | 3 | **Metrics (Y-Axis)** | Choose the aggregation for your Y-axis values. Options include Count, Sum, Average, Min, Max, and more. Scroll to see all available aggregations. | | 4 | **Buckets (X-Axis)** | Define how data is grouped on the X-axis. Use Date Histogram for time-based charts or Terms for categorical grouping. | | 5 | **Split Series** | Add a sub-aggregation to break down data into multiple series. Select Terms to split by field values. | | 6 | **Sub-aggregation Field** | Choose the field to split your series by (e.g., `container_name`, `log_level`, `service`). | ## Visualization Types ### Line Chart Line charts display data points connected over time, ideal for tracking trends and identifying patterns in time-series data. ![Line Chart showing Successful Transactions](/img/osd-line-chart.png) **Best for:** - Tracking metrics over time (transactions, requests, errors) - Identifying trends and anomalies - Comparing before/after changes **Configuration:** 1. **Metric**: Choose Count (or Sum/Average for numeric fields) 2. **X-Axis**: Add a Date Histogram bucket on your timestamp field 3. **Split Series** (optional): Add a Terms or Filters bucket to compare multiple series **Example: Successful Transactions Over Time** - Metric: Count - X-Axis: Date Histogram on `timestamp` with auto interval - Split Series: Filters with query `message:Transaction complete.` → [View example in Playground](https://play.oodle.ai/logs/app/visualize#/edit/4ef9c1a0-edc0-11f0-b051-df37cf89c1fe) --- ### Bar Chart Bar charts compare categorical data across groups or time periods. Stacked bars show the composition of each group. ![Stacked Bar Chart showing Cart Operations](/img/osd-barchart.png) **Best for:** - Comparing volumes across categories - Showing composition (stacked bars) - Analyzing distribution by time with breakdowns **Configuration:** 1. **Metric**: Count or Sum 2. **X-Axis**: Date Histogram for time-series, or Terms for categories 3. **Split Series**: Terms bucket on a field (e.g., `container_name`, `log_level`) to stack by category **Example: Cart Operations Over Time** - Metric: Count - X-Axis: Date Histogram on `timestamp` (5-minute intervals) - Split Series: Terms on `fingerprint.keyword` to see operation types → [View example in Playground](https://play.oodle.ai/logs/app/visualize#/edit/900d7c27-e8f8-4d95-9b47-a1773733a924) --- ### Area Chart Area charts show magnitude over time with filled regions. Stacked area charts display how parts contribute to a whole. ![Stacked Area Chart showing Log Count by Container](/img/osd-area-chart.png) **Best for:** - Visualizing volume trends over time - Showing proportional contributions from different sources - Highlighting sudden changes in data volume **Configuration:** 1. **Metric**: Count 2. **X-Axis**: Date Histogram on `timestamp` 3. **Split Series**: Terms bucket on a categorical field 4. In **Metrics & axes**, set Chart type to "Area" and enable "Stacked" **Example: Log Volume by Container** - Metric: Count - X-Axis: Date Histogram on `timestamp` (5-minute intervals) - Split Series: Terms on `container_name.keyword` → [View example in Playground](https://play.oodle.ai/logs/app/visualize#/edit/4dbd9c50-edb9-11f0-b051-df37cf89c1fe) --- ### Pie Chart Pie and donut charts show proportional distribution of data across categories. ![Pie Chart showing Transactions by Loyalty Level](/img/osd-pie-chart.png) **Best for:** - Showing relative proportions - Distribution across a small number of categories (≤10) - Understanding composition at a glance **Configuration:** 1. **Metric**: Count (or Sum for numeric values) 2. **Split Slices**: Terms bucket on the field to segment by 3. Optionally enable "Donut" mode in Options **Example: Transactions by Customer Loyalty Level** - Metric: Count - Split Slices: Filters bucket with queries for each level: - `message:Platinum` → label "Platinum" - `message:Gold` → label "Gold" - `message:Silver` → label "Silver" - `message:Bronze` → label "Bronze" → [View example in Playground](https://play.oodle.ai/logs/app/visualize#/edit/23a5fa40-edbc-11f0-b901-edc57b66473a) --- ### Metric Metric visualizations display a single prominent number, ideal for KPIs and summary statistics. ![Metric showing Total Error Count by Service](/img/osd-metric-type.png) **Best for:** - Displaying key performance indicators - Showing totals or averages at a glance - Dashboard summary panels **Configuration:** 1. **Metric**: Count, Sum, Average, or other aggregation 2. **Split Group** (optional): Terms bucket to show multiple metrics side by side **Example: Error Count by Service** - Metric: Count - Query filter: `log_level:error OR level:ERROR` - Split Group: Terms on `container_name.keyword` (top 3-5) → [View example in Playground](https://play.oodle.ai/logs/app/visualize#/edit/8c3e6f40-edb9-11f0-b051-df37cf89c1fe) --- ### Data Table Data tables display aggregated data in tabular format, perfect for detailed breakdowns and exports. ![Data Table showing Top Log Fingerprints](/img/osd-data-table.png) **Best for:** - Detailed data exploration - Exporting aggregated data - Showing exact counts and values **Configuration:** 1. **Metric**: Count (and additional metrics as needed) 2. **Split Rows**: Terms bucket on the field to list 3. Configure sort order (descending for "top N" lists) **Example: Top Log Fingerprints** - Metric: Count - Split Rows: Terms on `fingerprint.keyword` with size 10 - Sort by Count descending → [View example in Playground](https://play.oodle.ai/logs/app/visualize#/edit/7b780660-2b10-11f0-8d5a-b38158070b23) --- ### Heat Map Heat maps use color intensity to represent value magnitude across two dimensions, perfect for spotting patterns across time and categories. ![Heat Map showing Log Activity by Container and Hour](/img/osd-head-map.png) **Best for:** - Identifying activity patterns across time and categories - Spotting outliers in multi-dimensional data - Understanding when and where activity occurs **Configuration:** 1. **Metric**: Count (determines color intensity) 2. **X-Axis**: Date Histogram on `timestamp` 3. **Y-Axis**: Terms bucket on the categorical field **Example: Log Activity by Container Over Time** - Metric: Count - X-Axis: Date Histogram on `timestamp` (1-hour intervals) - Y-Axis: Terms on `container_name.keyword` → [View example in Playground](https://play.oodle.ai/logs/app/visualize#/edit/0d169890-edba-11f0-b051-df37cf89c1fe) --- ## Filtering Data You can filter visualization data in several ways: ### Query Bar Enter a Lucene query to filter documents: ``` log_level:error AND container_name:frontend ``` For the full syntax, see [Query Logs](/logs-visualization#query-logs). ### Filters Aggregation Use Filters as a bucket type to create labeled segments based on queries: ``` # Filter 1 Query: message:success Label: Successful # Filter 2 Query: message:failed OR message:error Label: Failed ``` ### Time Filter Use the time picker in the top navigation to adjust the time range for all visualizations. ## Saving Visualizations 1. Click **Save** in the top menu 2. Enter a descriptive title 3. Optionally add to an existing dashboard 4. Click **Save** Saved visualizations can be: - Added to multiple dashboards - Shared with team members - Used as a template for similar visualizations ## Next Steps - Combine your visualizations into [dashboards](/dashboards/logs/log-dashboards) for comprehensive monitoring views - Explore the [logs interface](/logs-visualization) for ad-hoc log analysis --- ## Create a Dashboard This guide walks you through creating a new dashboard in Oodle. ## Create a New Dashboard 1. Click **Dashboards** in the left sidebar. 2. Click the **New** button near the top right of the page. 3. Choose **New dashboard** from the dropdown menu. Now you should have a new blank dashboard. ## Add a New Panel 1. Click **Add visualization** on the empty dashboard screen to add a new panel. 2. Click **oodle** on the "Select data source" dialog. ## Configure the Panel Panels come with extensive configuration options. Let's take a look at the configuration options for [Cluster CPU Utilization panel from Oodle Playground]( https://play.oodle.ai/grafana/d/k8s_views_global/kubernetes-views-global?editPanel=72): 1. See how the panel will look like on the dashboard in the **visualization preview**. 2. Choose the visualization type from the **visualization dropdown**. 3. Write **PromQL query** to fetch the data for the panel, 4. Or use the **query builder** UI to build the query. 5. **Customize the panel** both in appearance and functionality using the provided options. --- ## Dashboard Variables Dashboard variables allow you to create interactive, dynamic dashboards. They can be used in queries and panel titles to create dashboards that can be easily modified to show different data. ## Create a Variable To add a variable to your dashboard: 1. Click the **Edit** button in the top-right corner of the dashboard. 2. Click the **Settings** button that just appeared. 3. The **Settings** page shows up. 1. Choose the **Variables** tab. 2. This page has link to the detailed documentation for variables if needed. 3. Click **Add variable** button. In the next section we'll take a deeper look at some of the most useful and commonly used variable types. ## Types of Variables Oodle supports all the variable types [supported by Grafana]( https://grafana.com/docs/grafana/latest/dashboards/variables/). Among those, two most useful and commonly used are **Query**, and **Ad hoc filters**. While the [Grafana docs]( https://grafana.com/docs/grafana/latest/dashboards/variables/ ) cover the technical details, here we'll focus on how to configure them, and how to use them in dashboards. ### Query Variable Query variables are variables that show up as a dropdown in the dashboard, where the values shown in the dropdown are determined by running a PromQL query. Let's look at the `namespace` variable in the [Kubernetes / Views / Namespaces]( https://play.oodle.ai/grafana/d/k8s_views_ns/kubernetes-views-namespaces ) dashboard on Oodle Playground. #### Appearance This variable appears as a dropdown in the dashboard, where you can select the kubernetes namespace. #### Configuration Here's how the [configuration page for `namespace`]( https://play.oodle.ai/grafana/d/k8s_views_ns/kubernetes-views-namespaces?editview=variables&editIndex=2 ) looks like: 1. **Name** specifies the name that will be used in the dashboard to reference the variable. 2. **Query** is used to fetch the variable values, which will be displayed in the dropdown. In this example, it means: 1. Query the metric `kube_pod_info` 2. Only consider the timeseries where `cluster` label equals the value of `cluster` variable. Note how configuration of `namespace` variable makes use of another variable, `cluster`. Also note how `$cluster` is used to reference the `cluster` variable. 3. From the resulting timeseries, take the value of `namespace` label. 4. Take the distinct set of values from the `namespace` label, and populate the dropdown. 3. **Multi-value** allows to select multiple values from the dropdown. 4. **Include All option** allows to include the special "All" value in the dropdown. 5. **Preview of values** shows all the values that will be present in the dropdown. #### Usage The [Kubernetes Resource Count panel]( https://play.oodle.ai/grafana/d/k8s_views_ns/kubernetes-views-namespaces?editPanel=32 ) uses the `namespace` variable by adding `$namespace` appropriately in all the queries of the panel that need to filter by namespace: ### Ad-hoc Filters #### Appearance Ad-hoc filters show up as double-dropdowns in the dashboard, where you can select: 1. The PromQL label to filter by 2. The value of the label to filter by #### Configuration Ad-hoc filters are configured by choosing **Ad-hoc filters** in "Select variable type" dropdown. #### Usage Unlike query variables, which need to be referenced in the query using `$variable_name`, ad-hoc filters don't need to be referenced in the query at all. Instead, they are automatically applied to all the queries in the dashboard. --- ## Terraform for dashboards Configure all your Grafana dashboards and folders through terraform. ## Advantages of managing dashboards as code * They are version-controlled, making changes and rollbacks more transparent and trackable. * This approach allows you to standardize dashboard configurations across environments, eliminating manual errors and drift between staging and production. You can apply the same dashboard configuration across all your deployments / environments. * Terraform's declarative nature also makes it easy to spin up or tear down dashboards alongside your infrastructure, ensuring your monitoring visualizations evolve with your stack. * Integrating dashboard management into your CI/CD pipeline means faster, more reliable deployments, as your dashboards will always align with the infrastructure they monitor. ## How :::info Use oodle's [Terraform Provider]( https://registry.terraform.io/providers/oodle-ai/oodle/latest/docs/resources/grafana_dashboard) ::: ### Prerequisites To setup Oodle Dashboards via Terraform files, you'll need the following values: - `OODLE_INSTANCE`: Your Oodle instance ID. Go to `Settings` icon -> `API Keys` page in your Oodle UI to find out. (Oodle UI links: ) - `OODLE_API_KEY`: Your Oodle API key for authentication. Go to `Settings` icon -> `API Keys` in your Oodle UI to choose an appropriate key. (Oodle UI links: ) ### Example usage ```hcl terraform { required_providers { oodle = { source = "registry.terraform.io/oodle-ai/oodle" } } } # These can also be set as environment variables: # export OODLE_DEPLOYMENT=https://us1.oodle.ai/ # export OODLE_INSTANCE="my-instance" # export OODLE_API_KEY="my-api-key" provider "oodle" { deployment_url = "https://us1.oodle.ai/" instance = "my-instance" api_key = "my-api-key" } # Example of creating a Grafana folder and dashboard together # The dashboard references the folder's UID resource "oodle_grafana_folder" "my_folder" { title = "Test Folder" } resource "oodle_grafana_dashboard" "test_folder" { folder = oodle_grafana_folder.my_folder.uid config_json = jsonencode({ "title" : "My Dashboard Title", "uid" : "my-dashboard-uid", "schemaVersion" : 39, "timezone" : "browser", "panels" : [ { "id" : 1, "type" : "stat", "title" : "Request Rate", "gridPos" : { "h" : 8, "w" : 12, "x" : 0, "y" : 0 }, "targets" : [ { "expr" : "sum(rate(http_requests_total[5m]))", "refId" : "A" } ] }, { "id" : 2, "type" : "timeseries", "title" : "Error Rate", "gridPos" : { "h" : 8, "w" : 12, "x" : 12, "y" : 0 }, "targets" : [ { "expr" : "sum(rate(http_requests_total{status=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m]))", "refId" : "A" } ] } ] }) } # Example of a dashboard with a commit message for version history resource "oodle_grafana_dashboard" "service_dashboard" { folder = oodle_grafana_folder.my_folder.uid message = "Initial dashboard creation" overwrite = true config_json = jsonencode({ "title" : "Service Health", "uid" : "service-health-dashboard", "schemaVersion" : 39, "time" : { "from" : "now-6h", "to" : "now" }, "panels" : [ { "id" : 1, "type" : "gauge", "title" : "Uptime", "gridPos" : { "h" : 8, "w" : 8, "x" : 0, "y" : 0 }, "targets" : [ { "expr" : "avg(up{job=\"my-service\"})", "refId" : "A" } ] } ] }) } # Example without a folder (created at root level) resource "oodle_grafana_dashboard" "root_dashboard" { config_json = jsonencode({ "title" : "Root Level Dashboard", "uid" : "root-level-dashboard", "schemaVersion" : 39, "panels" : [] }) } ``` Apply terraform configuration ```bash terraform init terraform apply ``` --- ## Metrics Dashboards Oodle dashboards are a way to query, visualize, and organize metrics to gain deeper insights into your data at a glance. ## Overview Let's take a look at the [Kubernetes Overview dashboard on Oodle Playground]( https://play.oodle.ai/grafana/d/k8s_views_global/kubernetes-views-global): 1. **Dashboards button** on the left sidebar provides quick access to Oodle dashboards. 2. **Dashboard path** shows your current location in the dashboard hierarchy, including folder structure and dashboard name. You can navigate back through folders using this breadcrumb trail. 3. **Variables** allow you to filter and refine your view. 4. **Time controls** let you set the time range for dashboard data. 5. **Visualization panels** show the data using a variety of visualizations. ## Explore Further export const FilteredDocCardList = () => { const category = useCurrentSidebarCategory(); const filteredItems = category.items.filter( item => item.label !== 'Metrics Dashboards' ); return ; }; --- ## Database Hosts The Hosts tab provides a host-level view of your monitored database instances, showing key performance and resource utilization metrics. ## Hosts Table | Column | Description | |--------|-------------| | **Database Host** | Instance hostname or endpoint | | **Queries/s** | Average queries per second | | **Total Duration** | Cumulative query execution time | | **Avg Duration** | Mean query execution time | | **Max Duration** | Longest single query execution | | **Rows/s** | Rows processed per second | | **Avg CPU Utilization** | Average CPU usage percentage | | **Max CPU Utilization** | Peak CPU usage percentage | ## Filters The left-hand panel provides: - **Database Type** — Filter by engine (PostgreSQL, MySQL). Each type is shown with its logo icon. - **Version** — Filter by database version. - **Database Identifier** — Search by DB instance identifier. ## Click to Drill Down Click a host row to open a detail drawer with: - **Overview metrics** — CPU utilization, active connections, queries per second, and replication lag over time. - **Databases table** — Lists each database on the host with its size, active connections, commits, rollbacks, and conflict count. - **Replication metrics** — Write lag, flush lag, replay lag, and replication slot delay (when applicable). ## Use Cases - **Capacity planning** — Identify hosts approaching CPU or connection limits. - **Performance debugging** — Correlate host-level resource spikes with query duration increases. - **Fleet management** — Compare performance across database instances to find outliers. ## Related Pages - [Database Queries](/database-monitoring/queries) — Aggregated query performance metrics. - [Database Samples](/database-monitoring/samples) — View individual query execution events. --- ## Database Monitoring Oodle Database Monitoring gives you deep visibility into your database performance. Track query execution times, identify slow queries, monitor host resource utilization, and analyze query samples — all from a single interface. ## Getting Started Navigate to **Database Monitoring** in the sidebar. The default view opens on the **Queries** tab, which shows an aggregated view of all database queries. ## Navigation Database Monitoring is organized into three tabs: | Tab | Description | |-----|-------------| | **[Queries](/database-monitoring/queries)** | Aggregated query performance metrics | | **[Samples](/database-monitoring/samples)** | Individual query execution samples | | **[Hosts](/database-monitoring/hosts)** | Database host health and resource usage | ## Filters Each tab provides a filter sidebar on the left. The **Queries** and **Samples** tabs share these filters: - **Host** — Filter by database host. - **Database** — Filter by database name. - **User** — Filter by the database user that executed the query. - **Tables** — Filter by referenced table names. - **Commands** — Filter by SQL command type (e.g., SELECT, INSERT). The **Hosts** tab has its own filters: - **Database Type** — Filter by engine (PostgreSQL, MySQL). - **Version** — Filter by database version. - **Database Identifier** — Search by DB instance identifier. All tabs include a **time picker** to adjust the observation window. ## Supported Databases Oodle Database Monitoring supports: - **PostgreSQL** (including Aurora PostgreSQL) - **MySQL** (including Aurora MySQL) ## Best Practices - **Start with the Queries tab** to identify the most expensive queries by total duration. - **Drill into Samples** when you need to see individual execution plans or parameter values. - **Monitor Hosts** to detect resource bottlenecks (CPU, connections) that affect query performance. - **Set alerts** on key database metrics (query duration, error rate) to catch regressions early. ## Related Pages - [Kubernetes](/kubernetes/) — Monitor Kubernetes clusters, workloads, and pods. - [Serverless](/serverless/) — Monitor AWS Lambda functions. --- ## Database Queries The Queries tab shows aggregated performance metrics for every normalized database query. Use it to identify slow queries, high- volume queries, and queries that consume the most total database time. ## Overview Chart At the top of the page, a time-series chart visualizes the selected metric over time. Use the dropdown to switch between: - **Total Duration** — Cumulative execution time across all executions. - **Avg Duration** — Mean execution time per query call. - **Count** — Number of executions. ## Queries Table Below the chart, the table lists every normalized query: | Column | Description | |--------|-------------| | **Query** | Normalized SQL statement (parameters replaced with placeholders) | | **Count** | Number of times the query ran in the selected window | | **Total Duration** | Cumulative execution time with a visual bar | | **Avg Duration** | Average execution time | | **Avg Rows** | Average number of rows returned or affected | | **Cache Hit Ratio** | Percentage of executions served from cache | | **% Duration** | Share of total database time consumed by this query | ### Sorting Click any column header to sort. Sorting by **Total Duration** (descending) is the most effective way to find the queries that matter most. ### Search Use the search bar to filter queries by SQL text. ### Column Selector Click the column-selector button in the toolbar to toggle additional columns on or off. Hidden by default are block-level I/O columns such as Avg Shared Blocks Hit/Read, Avg Local Blocks Hit/Read, and Avg Temp Blocks Read/Written. These are useful for diagnosing I/O-heavy queries. ### Show Full Query Toggle **Show Full Query** in the toolbar to expand truncated SQL statements in the table so you can read the complete query text without clicking into the detail drawer. ## Filters The left-hand panel provides: - **Host** — Filter by database host. - **Database** — Filter by database name. - **User** — Filter by the database user that executed the query. - **Tables** — Filter by referenced table names. - **Commands** — Filter by SQL command type (e.g., SELECT, INSERT). ## Click to Drill Down Click any query row to see: - A detailed execution timeline. - Per-host breakdown of execution statistics. - Link to related [Samples](/database-monitoring/samples) for that query. ## Related Pages - [Database Samples](/database-monitoring/samples) — View individual query execution events. - [Database Hosts](/database-monitoring/hosts) — Monitor host-level performance. --- ## Database Samples The Samples tab displays individual query execution events captured from your databases. Unlike the aggregated Queries view, Samples shows each execution with its exact timestamp, full SQL statement, and the database instance it ran on. ## Samples Table | Column | Description | |--------|-------------| | **Time** | Exact timestamp of the execution. | | **Statement** | Normalized SQL statement (displayed in monospace font). | | **Database Instance** | The host the query ran on. | | **User** | The database user that executed the query. | | **Wait Event Group** | The wait-event category — color-coded badges for CPU, IO, Network, and Lock. | | **Database** | The database name. | | **Duration** | Execution duration, color-coded by severity. | | **Application** | The client application name (if reported). | | **State** | Current state of the query (e.g., active, idle). | :::tip The table shows the latest 1,000 samples. If the limit is reached, a note appears above the table. Narrow the time range to load more samples. ::: ## Filters The left-hand panel provides: - **Host** — Filter by database host. - **Database** — Filter by database name. - **User** — Filter by the database user. - **Tables** — Filter by referenced table names. - **Commands** — Filter by SQL command type (e.g., SELECT, INSERT). ### Search Use the search bar to filter samples by SQL text, host, database, or user. ## Click to Drill Down Click any sample row to open a detail drawer showing the full query text, execution plan (when available), and contextual details like client application name and user. ## Use Cases - **Debug slow queries** — Find specific executions that were abnormally slow and inspect their details. - **Audit activity** — Review which queries ran at a specific time against a specific database. - **Investigate deadlocks** — Examine the exact statements involved in a deadlock event. - **Validate deployments** — After a schema migration, verify that new query patterns appear as expected. ## Related Pages - [Database Queries](/database-monitoring/queries) — Aggregated query performance metrics. - [Database Hosts](/database-monitoring/hosts) — Monitor host-level performance. --- ## Amazon ECS Oodle provides a seamless integration with [Elastic Container Service](https://aws.amazon.com/ecs/) and [AWS Fargate](https://aws.amazon.com/fargate/) to ingest **metrics, logs, and traces** from your ECS cluster. The quickest path for all three signals is the **Datadog Agent**, which ships metrics, logs, and traces together; see [Datadog Agent](#datadog-agent) below. If you only need logs, the [log routers](#configuration) (FireLens, OpenTelemetry, CloudWatch) cover that on their own. ## Datadog Agent If the [Datadog Agent](https://docs.datadoghq.com/integrations/ecs_fargate/) runs in your ECS tasks (injected as a sidecar on **Fargate**, or as a daemon task on **EC2**), point it at Oodle by adding environment variables to the **Agent container**, with no application changes required. This carries **metrics, logs, and traces** together, unlike the log-only routers below. **Dual-write** (Datadog + Oodle) adds Oodle as additional endpoints; keep your existing `DD_API_KEY` / `DD_SITE`: ```json "environment": [ { "name": "DD_ADDITIONAL_ENDPOINTS", "value": "{\"\": [\"\"]}" }, { "name": "DD_USE_V3_API_SERIES_ENABLED", "value": "false" }, { "name": "DD_LOGS_CONFIG_ADDITIONAL_ENDPOINTS", "value": "[{\"api_key\": \"\", \"Host\": \"\", \"Port\": 443, \"is_reliable\": false}]" }, { "name": "DD_LOGS_CONFIG_FORCE_USE_HTTP", "value": "true" }, { "name": "DD_APM_ADDITIONAL_ENDPOINTS", "value": "{\"\": [\"\"]}" } ] ``` **Single-write** (Oodle only) overrides the Agent's primary endpoints and authenticates with your Oodle API key: ```json "environment": [ { "name": "DD_API_KEY", "value": "" }, { "name": "DD_DD_URL", "value": "" }, { "name": "DD_USE_V3_API_SERIES_ENABLED", "value": "false" }, { "name": "DD_APM_DD_URL", "value": "" }, { "name": "DD_LOGS_CONFIG_LOGS_DD_URL", "value": ":443" }, { "name": "DD_LOGS_CONFIG_FORCE_USE_HTTP", "value": "true" } ] ``` :::note On the **EC2** launch type, also set `DD_ORCHESTRATOR_EXPLORER_ENABLED=false`. Orchestrator Explorer ships to Datadog's site-based process intake, which `DD_DD_URL` cannot redirect to Oodle. ::: For the full reference (Helm/Operator/YAML elsewhere, plus a runnable `terraform-aws-ecs-datadog` example for Fargate and EC2), see the [Datadog on ECS guide](/integrations/metrics/datadog#amazon-ecs). ## Configuration To configure Oodle to ingest logs from your ECS cluster, you'll need the following values: - `OODLE_INSTANCE`: Your Oodle instance ID. Go to `Settings` icon -> `API Keys` page in your Oodle UI to find out. (Oodle UI links: ) - `OODLE_API_KEY`: Your Oodle API key for authentication. Go to `Settings` icon -> `API Keys` in your Oodle UI to choose an appropriate key. (Oodle UI links: ) - `OODLE_LOGS_HOST`: The Oodle Host for log ingestion. Go to `Settings` -> `Integrations` -> `Logs` -> `Fluent Bit` in your Oodle UI to find out. - `OODLE_METRICS_HOST`: The Oodle Host for metrics ingestion. Go to `Settings` -> `Integrations` -> `Metrics` -> `Prometheus` in your Oodle UI to find out. ### Using AWS FireLens If you are using [awslogs](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/using_awslogs.html) log driver to send logs to CloudWatch, [AWS FireLens](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/using_firelens.html) is recommended for routing logs to external destinations. Oodle uses Fluent Bit with FireLens for routing logs. #### Customer-Hosted Fluent Bit Configs It is recommended to host Fluent Bit configs in the customer's S3 buckets as that allows maximum flexibility in terms of being able to version-control the configs, and modifying the config for specific needs. It also simplifies ECS task definitions as config files can be directly pulled from customer's S3 bucket. 1. Store your fluent bit config in a S3 bucket. Following is an example config which configures dual-write of logs to CloudWatch and Oodle, and also send Fluent Bit internal metrics to Oodle. It also adds following filters: - [multiline](https://github.com/aws-samples/amazon-ecs-firelens-examples/tree/mainline/examples/fluent-bit/filter-multiline-partial-message-mode) to concatenate partial/split container logs due to container runtime split. ```conf [INPUT] name fluentbit_metrics tag internal_metrics scrape_interval 2 [FILTER] name multiline match * multiline.key_content log mode partial_message [OUTPUT] Name cloudwatch_logs Match * region ${AWS_REGION} log_group_name ${CLOUDWATCH_LOG_GROUP} log_stream_name ${CLOUDWATCH_LOG_STREAM}/${ECS_TASK_ID} auto_create_group true Retry_Limit 3 [OUTPUT] Name http Match * Host ${OODLE_LOGS_HOST} Port 443 URI /ingest/v1/logs Header X-OODLE-INSTANCE ${OODLE_INSTANCE} Header X-API-KEY ${OODLE_API_KEY} Format json Compress gzip Json_date_key timestamp Json_date_format iso8601 Tls On Retry_Limit 3 [OUTPUT] Name prometheus_remote_write Match internal_metrics Host ${OODLE_METRICS_HOST} Port 443 Uri /v1/prometheus/${OODLE_INSTANCE}/write Header X-API-KEY ${OODLE_API_KEY} Log_response_payload True Tls On # These environment variables are added by aws-for-fluent-bit init container add_label ecs_cluster ${ECS_CLUSTER} add_label ecs_task_arn ${ECS_TASK_ARN} add_label ecs_task_definition ${ECS_TASK_DEFINITION} add_label ecs_task_id ${ECS_TASK_ID} Retry_Limit 3 ``` :::note Depending on your needs, you can update the fluent bit config, e.g. to only send logs to Oodle, you can remove `cloudwatch_logs` Output section in the config. ::: 2. In the ECS task definition, update the value of `logDriver` from `awslogs` to `awsfirelens` for the application container. ```json { "logConfiguration": { "logDriver": "awsfirelens" } } ``` 3. In the ECS task definition, add a Fluent Bit sidecar container to handle log routing. Provide relevant environment variables referenced in the fluent bit config, and provide fluent bit config via `aws_fluent_bit_init_s3_1` environment variable. ```json { "name": "fluent-bit", "image": "public.ecr.aws/aws-observability/aws-for-fluent-bit:init-2.32.5.20250305", "essential": false, "memory": 200, "environment": [ { "name": "OODLE_INSTANCE", "value": "" }, { "name": "OODLE_API_KEY", "value": "" }, { "name": "OODLE_LOGS_HOST", "value": "" }, { "name": "OODLE_METRICS_HOST", "value": "" }, { "name": "CLOUDWATCH_LOG_GROUP", "value": "" }, { "name": "CLOUDWATCH_LOG_STREAM", "value": "" }, { "name": "aws_fluent_bit_init_s3_1", "value": "/fluent-bit.conf>" } ], "firelensConfiguration": { "type": "fluentbit" }, "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-region": "", "awslogs-group": "", "awslogs-stream-prefix": "" } } } ``` 4. Ensure your ECS [task role](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html) has permissions to download from S3 bucket. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:GetObject", "s3:GetBucketLocation" ], "Resource": "*" } ] } ``` :::note If you want to restrict the permissions to the specific bucket where your Fluent Bit configs are hosted, then you can update `Resource` to `arn:aws:s3:::/*` ::: 5. If you are writing logs from Fluent Bit to CloudWatch as well, then ensure your ECS task role has relevant CloudWatch permissions as well. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "arn:aws:logs:::log-group::*" } ] } ``` #### Oodle-Hosted Fluent Bit Configs If you prefer to use Oodle-Hosted Fluent Bit configs, then you can follow steps in this section. ##### Send to CloudWatch and Oodle In this configuration, ECS application container logs are sent to both CloudWatch and Oodle. Sidecar containers logs are sent to CloudWatch. 1. In the application container definition, update the value of `logDriver` from `awslogs` to `awsfirelens`. ```json { "logConfiguration": { "logDriver": "awsfirelens" } } ``` 2. Add an ephemeral volume in the ECS task definition. This volume will be used to store Fluent Bit configuration file as configured in Step #3. ```json "volumes": [ { "name": "config", "host": {} } ] ``` 3. Add two containers to the ECS task definition: - An init container to download Fluent Bit configuration file - A Fluent Bit sidecar container to handle log routing to both CloudWatch and Oodle. ```json { "name": "config-init", "image": "public.ecr.aws/docker/library/alpine:3.21.3", "essential": false, "memoryReservation": 64, "command": [ "sh", "-c", "set -e && apk add --no-cache ca-certificates wget && wget -O /oodle/fluent-bit.conf https://oodle-configs.s3.us-west-2.amazonaws.com/logs/ecs/fluent-bit/fluent-bit-cloudwatch-v3.conf || exit 1" ], "mountPoints": [ { "sourceVolume": "config", "containerPath": "/oodle", "readOnly": false } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-region": "", "awslogs-group": "", "awslogs-stream-prefix": "" } } }, { "name": "fluent-bit", "image": "public.ecr.aws/aws-observability/aws-for-fluent-bit:init-2.32.5.20250305", "essential": false, "memory": 200, "environment": [ { "name": "OODLE_INSTANCE", "value": "" }, { "name": "OODLE_API_KEY", "value": "" }, { "name": "OODLE_LOGS_HOST", "value": "" }, { "name": "OODLE_METRICS_HOST", "value": "" }, { "name": "CLOUDWATCH_LOG_GROUP", "value": "" }, { "name": "CLOUDWATCH_LOG_STREAM", "value": "" } ], "mountPoints": [ { "sourceVolume": "config", "containerPath": "/oodle", "readOnly": true } ], "dependsOn": [ { "containerName": "config-init", "condition": "COMPLETE" } ], "firelensConfiguration": { "type": "fluentbit", "options": { "config-file-type": "file", "config-file-value": "/oodle/fluent-bit.conf" } }, "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-region": "", "awslogs-group": "", "awslogs-stream-prefix": "" } } } ``` 4. Ensure your ECS [task role](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html)) has relevant CloudWatch permissions. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "arn:aws:logs:::log-group::*" } ] } ``` ##### Send to Oodle Only In this configuration, ECS application container logs are sent to Oodle only. Sidecar containers logs are sent to CloudWatch. 1. Configure the application container and volume (Steps 1-2 above). 2. Update the `config-init` container definition. ```json { "name": "config-init", "image": "public.ecr.aws/docker/library/alpine:3.21.3", "essential": false, "memoryReservation": 64, "command": [ "sh", "-c", "set -e && apk add --no-cache ca-certificates wget && wget -O /oodle/fluent-bit.conf https://oodle-configs.s3.us-west-2.amazonaws.com/logs/ecs/fluent-bit/fluent-bit-v3.conf || exit 1" ], "mountPoints": [ { "sourceVolume": "config", "containerPath": "/oodle", "readOnly": false } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-region": "", "awslogs-group": "", "awslogs-stream-prefix": "" } } } ``` The `config-init` container downloads a simplified configuration file from [fluent-bit-v3.conf](https://oodle-configs.s3.us-west-2.amazonaws.com/logs/ecs/fluent-bit/fluent-bit-v3.conf) that routes logs exclusively to Oodle. In addition, environment variables `CLOUDWATCH_LOG_GROUP` and `CLOUDWATCH_LOG_STREAM` can be removed from the `fluent-bit` container. #### Optional Configurations This section documents configurations which can be used if you have the specified use-cases. ##### JSON Logs If your applications emit logs in json format, then you can add a fluent parser to parse the json logs before routing them to destinations. It makes use of [multi-config-support](https://github.com/aws-samples/amazon-ecs-firelens-examples/tree/mainline/examples/fluent-bit/multi-config-support) and [parse-json](https://github.com/aws-samples/amazon-ecs-firelens-examples/tree/mainline/examples/fluent-bit/parse-json) capability in AWS for Fluent Bit image. To add `parse-json` parser in your `fluent-bit` configuration, you can add following environment variable to `fluent-bit` container: ```json { "name": "aws_fluent_bit_init_file_1", "value": "/fluent-bit/configs/parse-json.conf" } ``` The default parser used in AWS for Fluent Bit image uses `Time_Format %d/%b/%Y:%H:%M:%S %z` to parse the timestamp from the logs. If your application logs have a different timestamp format, you can override the json parser. As an example, if your timestamp format is ISO8601 (`%Y-%m-%dT%H:%M:%S.%LZ`), then you can follow below steps: 1. Update `config-init` container command to download custom json parser related configuration files, add following `wget` commands in the command used in `config-init` container: ``` wget -O /oodle/parser.conf https://oodle-configs.s3.us-west-2.amazonaws.com/logs/ecs/fluent-bit/fluent-bit-parser-v1.conf && wget -O /oodle/parse-json.conf https://oodle-configs.s3.us-west-2.amazonaws.com/logs/ecs/fluent-bit/fluent-bit-filter-parse-json-iso8601-v1.conf ``` 2. Add following environment variables in `fluent-bit` container: ``` { "name": "aws_fluent_bit_init_file_1", "value": "/oodle/parser.conf" }, { "name": "aws_fluent_bit_init_file_2", "value": "/oodle/parse-json.conf" } ``` :::note Parser definitions needs to be provided as separate file and cannot be merged with Filter definitions in a single file. You can choose to store these config files in your S3 bucket as well, and use `aws_fluent_bit_init_s3_` environment variables to refer to them. ::: ##### Concatenate Partial/Split Container Logs Container runtime splits log lines larger than 16KB into multiple log lines. Fluent Bit can concatenate these split log lines using `multiline` filter. To use it, add following filter in your fluent bit configuration: ```conf [FILTER] name multiline match * multiline.key_content log mode partial_message ``` :::note This needs to be first filter in the pipeline so that all remaining filters operate on concatenated log lines. ::: ##### Enrich log lines with additional key-value pair Say, you want to ingest your dev and prod cluster logs to Oodle. You can enrich each log line with a `environment: dev` or `environment: prod` field by making following changes: 1. Update `config-init` container command to download custom modify filter configuration file, add following `wget` command in the command used in `config-init` container: ``` wget -O /oodle/modify-add-field.conf https://oodle-configs.s3.us-west-2.amazonaws.com/logs/ecs/fluent-bit/fluent-bit-modify-add-field-v1.conf ``` 2. Add following environment variables in `fluent-bit` container: ``` { "name": "FIELD_NAME", "value": "environment" }, { "name": "FIELD_VALUE", "value": "dev" }, { "name": "aws_fluent_bit_init_file_1", "value": "/oodle/modify-add-field.conf" }, ``` :::note The numbering in `aws_fluent_bit_init_file_*` can be used to add multiple init files, e.g. if you need both json parsing and enrich field, then you can add all files needed with increasing counter. ::: ### Using OpenTelemetry Collector Users can run [OpenTelemetry (OTel) Collector contrib](https://github.com/open-telemetry/opentelemetry-collector-contrib) image to send ECS logs to Oodle. :::note 1. [AWS Distro for OpenTelemetry](https://aws-otel.github.io) image is not supported as it doesn't contain the [Fluent Forward Receiver](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/receiver/fluentforwardreceiver) required by this configuration. 1. [Fluent Forward Receiver](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/receiver/fluentforwardreceiver) is currenlty in **beta** status. ::: #### Send to CloudWatch and Oodle In this configuration, ECS application container logs are sent to both CloudWatch and Oodle. Sidecar containers logs are sent to CloudWatch. 1. In the application container definition, update the value of `logDriver` from `awslogs` to `awsfirelens`. ```json { "logConfiguration": { "logDriver": "awsfirelens" } } ``` Fluent Forward Receiver in OTel collector is used in this configuration. Therefore, the value of `logDriver` is set to `awsfirelens` and the `firelensConfiguration` type in next step is set to `fluentbit`. 2. Add an OTel collector sidecar container in the ECS task definiton. ```json { "name": "otel-collector", "image": "ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib:0.121.0", "essential": false, "memory": 200, "environment": [ { "name": "OODLE_INSTANCE", "value": "" }, { "name": "OODLE_API_KEY", "value": "" }, { "name": "OODLE_LOGS_HOST", "value": "" }, { "name": "CLOUDWATCH_LOG_GROUP", "value": "" }, { "name": "CLOUDWATCH_LOG_STREAM", "value": "" } ], "command": [ "--config", "https://oodle-configs.s3.us-west-2.amazonaws.com/logs/ecs/otel/otel-config-cloudwatch-v2.yaml" ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-region": "", "awslogs-group": "", "awslogs-stream-prefix": "" } }, "firelensConfiguration": { "type": "fluentbit" } } ``` 3. Ensure your ECS [task role](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html)) has relevant CloudWatch permissions. ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ], "Resource": "arn:aws:logs:::log-group::*" } ] } ``` OTeL collector downloads an [otel-config-cloudwatch-v2.yaml](https://oodle-configs.s3.us-west-2.amazonaws.com/logs/ecs/otel/otel-config-cloudwatch-v2.yaml) from a public S3 repository owned by Oodle. The config files in this repository are versioned and immutable. #### Send to Oodle Only In this configuration, ECS application container logs are sent to Oodle only. Sidecar containers logs are sent to CloudWatch. 1. Configure the application container (Step 1 above). 2. Update the `otel-collector` container definition. ```json { "name": "otel-collector", "image": "ghcr.io/open-telemetry/opentelemetry-collector-releases/opentelemetry-collector-contrib:0.121.0", "essential": false, "memory": 200, "environment": [ { "name": "OODLE_INSTANCE", "value": "" }, { "name": "OODLE_API_KEY", "value": "" }, { "name": "OODLE_LOGS_HOST", "value": "" } ], "command": [ "--config", "https://oodle-configs.s3.us-west-2.amazonaws.com/logs/ecs/otel/otel-config-v2.yaml" ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-region": "", "awslogs-group": "", "awslogs-stream-prefix": "" } }, "firelensConfiguration": { "type": "fluentbit" } } ``` The `otel-collector` container downloads a simplified configuration file from [otel-config-v2.yaml](https://oodle-configs.s3.us-west-2.amazonaws.com/logs/ecs/otel/otel-config-v2.yaml) that routes logs exclusively to Oodle. In addition, environment variables `CLOUDWATCH_LOG_GROUP` and `CLOUDWATCH_LOG_STREAM` are also removed from the `otel-collector` container. ## CloudWatch Logs Integration If you are already sending your ECS logs to CloudWatch and prefer to push logs from CloudWatch to Oodle instead of modifying your ECS task definitions, you can use our CloudWatch integration. This approach allows you to keep your existing logging setup while still getting your logs into Oodle. For detailed instructions on how to set up the CloudWatch to Oodle integration, please refer to our [CloudWatch integration guide](/integrations/logs/cloudwatch). --- ## Built-in Code Checks Built-in code checks are code evaluators that Oodle writes and maintains. You write no code: pick a check, set its settings, and choose the spans it scores. Like all code evaluators, they are fast and make no LLM call. ## Use a built-in check 1. Go to **Agent Observability → Evaluators** () 2. Click **Create Evaluator** and pick a check from **Built-in checks** 3. Set the check's settings, for example the keywords that the reply must mention 4. Set the filters, the sampling rate and the hourly cap, as for any evaluator 5. Click **Save** Each check is a managed template with the id `oodle-managed-code--v1`. Use that id as the template when you create an evaluator through the API (`evaluatorId`), the CLI or MCP (`templateId`): ```json {"action": "create_evaluator", "name": "Support replies mention refunds", "templateId": "oodle-managed-code-keyword-check-v1", "params": {"required": ["refund"], "mode": "any"}, "enabled": false} ``` The settings of each check are the keyword arguments of its library function. The [library reference](./oodle-eval-reference#metrics) gives each argument and its default. A setting that you do not set takes that default. ## The checks The first score of each check is its primary score. ### Format | Check | Template id | What it scores | Scores | Function | |-------|-------------|----------------|--------|----------| | JSON validity | `oodle-managed-code-json-validity-v1` | Output parses as JSON and has the keys, value types and schema you set | `is_json`, `json_schema_ok` | [`json_validity`](./oodle-eval-reference#metrics-json_validity) | | Regex match | `oodle-managed-code-regex-match-v1` | Output matches every required pattern and none of the forbidden ones | `regex_pass` | [`regex_match`](./oodle-eval-reference#metrics-regex_match) | | Keyword check | `oodle-managed-code-keyword-check-v1` | Output mentions the required keywords and none of the forbidden ones | `keywords_ok`, `keyword_coverage` | [`keyword_check`](./oodle-eval-reference#metrics-keyword_check) | | Contains | `oodle-managed-code-contains-v1` | Output contains the values you list, anywhere in the text | `contains` | [`contains`](./oodle-eval-reference#metrics-contains) | | Length budget | `oodle-managed-code-length-budget-v1` | Output stays inside a word and character range | `within_length` | [`length_budget`](./oodle-eval-reference#metrics-length_budget) | | Markdown structure | `oodle-managed-code-markdown-structure-v1` | Code fences are closed, tables are even, and the headings, lists and tables you require are there | `markdown_ok`, `heading_count` | [`markdown_structure`](./oodle-eval-reference#metrics-markdown_structure) | | HTML validity | `oodle-managed-code-html-validity-v1` | The HTML in the output has balanced tags, and only the tags you allow | `html_valid` | [`html_validity`](./oodle-eval-reference#metrics-html_validity) | | CSV validity | `oodle-managed-code-csv-validity-v1` | Output is CSV with the same number of fields in every row, and the columns you require | `csv_valid`, `row_count` | [`csv_validity`](./oodle-eval-reference#metrics-csv_validity) | | TOML validity | `oodle-managed-code-toml-validity-v1` | Output parses as TOML and has the keys you require | `toml_valid` | [`toml_validity`](./oodle-eval-reference#metrics-toml_validity) | | SQL shape | `oodle-managed-code-sql-shape-v1` | Generated SQL is only the statement types you allow, with no write keyword you did not allow | `sql_ok`, `statement_type` | [`sql_shape`](./oodle-eval-reference#metrics-sql_shape) | ### Text quality | Check | Template id | What it scores | Scores | Function | |-------|-------------|----------------|--------|----------| | Readability | `oodle-managed-code-readability-v1` | Flesch reading ease and Flesch-Kincaid grade, with a grade range to enforce | `readability_ok`, `reading_ease` | [`readability`](./oodle-eval-reference#metrics-readability) | | Language adherence | `oodle-managed-code-language-adherence-v1` | Output is written in the language you expect | `language_match`, `detected_language` | [`language_adherence`](./oodle-eval-reference#metrics-language_adherence) | ### Compare to a reference These checks compare the output with a **Reference** setting. In an experiment, a check with no reference set uses the dataset item's expected output. On a live span, set the reference. | Check | Template id | What it scores | Scores | Function | |-------|-------------|----------------|--------|----------| | Exact match | `oodle-managed-code-exact-match-v1` | Output equals a reference text | `exact_match` | [`exact_match`](./oodle-eval-reference#metrics-exact_match) | | Edit similarity | `oodle-managed-code-levenshtein-ratio-v1` | How close the output is to a reference text, by edit distance | `levenshtein_ratio` | [`levenshtein_ratio`](./oodle-eval-reference#metrics-levenshtein_ratio) | | Word overlap | `oodle-managed-code-jaccard-similarity-v1` | Share of words the output and a reference text have in common | `jaccard_similarity` | [`jaccard_similarity`](./oodle-eval-reference#metrics-jaccard_similarity) | | ROUGE | `oodle-managed-code-rouge-v1` | ROUGE-L, ROUGE-1 and ROUGE-2 F1 of the output against a reference text | `rouge_l`, `rouge_1`, `rouge_2` | [`rouge`](./oodle-eval-reference#metrics-rouge) | | BLEU | `oodle-managed-code-bleu-v1` | Sentence BLEU of the output against a reference text | `bleu` | [`bleu`](./oodle-eval-reference#metrics-bleu) | | Numeric difference | `oodle-managed-code-numeric-diff-v1` | How close the first number in the output is to a reference number | `numeric_diff` | [`numeric_diff`](./oodle-eval-reference#metrics-numeric_diff) | | JSON difference | `oodle-managed-code-json-diff-v1` | How close the output's JSON is to a reference JSON value, key by key | `json_diff` | [`json_diff`](./oodle-eval-reference#metrics-json_diff) | ### Safety and tone | Check | Template id | What it scores | Scores | Function | |-------|-------------|----------------|--------|----------| | Tone | `oodle-managed-code-tone-v1` | Flags shouting, too many exclamation marks, negative sentiment and banned phrases | `tone_ok`, `sentiment` | [`tone`](./oodle-eval-reference#metrics-tone) | | Sentiment | `oodle-managed-code-sentiment-v1` | Sentiment of the output from -1 (negative) to 1 (positive), from a word list | `sentiment`, `sentiment_label` | [`sentiment`](./oodle-eval-reference#metrics-sentiment) | | PII and secret leak | `oodle-managed-code-pii-leak-v1` | Output contains an email, phone, card number, national id, IP address or credential | `pii_free`, `pii_count` | [`pii_leak`](./oodle-eval-reference#metrics-pii_leak) | | URL check | `oodle-managed-code-url-check-v1` | URLs are well formed, use an allowed scheme and domain, and do not point at a private address | `urls_ok`, `url_count` | [`url_check`](./oodle-eval-reference#metrics-url_check) | | Prompt injection | `oodle-managed-code-prompt-injection-v1` | The input tries to override the model's instructions, directly or through a tool result | `injection_detected`, `injection_risk`, `injection_signals` | [`prompt_injection`](./oodle-eval-reference#metrics-prompt_injection) | | Refusal | `oodle-managed-code-refusal-v1` | The model declined to do what it was asked | `refused` | [`refusal`](./oodle-eval-reference#metrics-refusal) | ### Conversation | Check | Template id | What it scores | Scores | Function | |-------|-------------|----------------|--------|----------| | Conversation degeneration | `oodle-managed-code-conversation-degeneration-v1` | The assistant repeats itself, within the reply or across turns | `degenerated`, `degeneration` | [`conversation_degeneration`](./oodle-eval-reference#metrics-conversation_degeneration) | | Knowledge retention | `oodle-managed-code-knowledge-retention-v1` | The reply asks again for, or contradicts, facts the user already gave | `knowledge_retained`, `knowledge_retention` | [`knowledge_retention`](./oodle-eval-reference#metrics-knowledge_retention) | ### Context and relevance These checks compare the reply with the retrieved context. They find the context in this order: the `context` setting, then the span attribute that `context_key` names (`context` by default), then the text of the input messages. `bm25_relevance` and `tfidf_similarity` compare with the question by default; set `against` to `context` to compare with the context. | Check | Template id | What it scores | Scores | Function | |-------|-------------|----------------|--------|----------| | Grounding | `oodle-managed-code-grounding-v1` | Share of the reply's sentences whose words are in the retrieved context | `grounded`, `grounding` | [`grounding`](./oodle-eval-reference#metrics-grounding) | | Unsupported numbers | `oodle-managed-code-unsupported-numbers-v1` | Numbers and names in the reply that the retrieved context does not have | `numbers_supported`, `unsupported_count` | [`unsupported_numbers`](./oodle-eval-reference#metrics-unsupported_numbers) | | Citation check | `oodle-managed-code-citation-check-v1` | Every [n] marker and URL in the reply points at a source the context has | `citations_ok`, `citation_count` | [`citation_check`](./oodle-eval-reference#metrics-citation_check) | | BM25 relevance | `oodle-managed-code-bm25-relevance-v1` | How relevant the reply is to the question or the context, by BM25 | `bm25_relevance` | [`bm25_relevance`](./oodle-eval-reference#metrics-bm25_relevance) | | TF-IDF similarity | `oodle-managed-code-tfidf-similarity-v1` | Cosine similarity of the reply and the question or the context | `tfidf_similarity` | [`tfidf_similarity`](./oodle-eval-reference#metrics-tfidf_similarity) | ### Agent and cost The step checks (`span_count`, `error_spans`, `repeated_tool_calls`, `total_duration`) read the agent's steps in `ctx.trace.spans`, which an experiment fills. | Check | Template id | What it scores | Scores | Function | |-------|-------------|----------------|--------|----------| | Tool call validity | `oodle-managed-code-tool-call-validity-v1` | Tool calls have JSON arguments, use allowed tools, and do not loop | `tool_calls_valid`, `tool_call_loop` | [`tool_call_validity`](./oodle-eval-reference#metrics-tool_call_validity) | | Tool trajectory | `oodle-managed-code-tool-trajectory-v1` | The agent called the tools you expect, in order or as a set | `trajectory_match`, `tool_correctness` | [`tool_trajectory`](./oodle-eval-reference#metrics-tool_trajectory) | | Step count | `oodle-managed-code-span-count-v1` | How many steps the agent took, from its trace | `span_count`, `within_span_limit` | [`span_count`](./oodle-eval-reference#metrics-span_count) | | Failed steps | `oodle-managed-code-error-spans-v1` | How many of the agent's steps failed, from its trace | `no_errors`, `error_count` | [`error_spans`](./oodle-eval-reference#metrics-error_spans) | | Tool call loop | `oodle-managed-code-repeated-tool-calls-v1` | The agent calls the same tool with the same input again and again | `tool_loop`, `most_repeated` | [`repeated_tool_calls`](./oodle-eval-reference#metrics-repeated_tool_calls) | | Total duration | `oodle-managed-code-total-duration-v1` | How long the agent ran, from its trace | `total_duration_ms`, `within_duration` | [`total_duration`](./oodle-eval-reference#metrics-total_duration) | | Token and latency budget | `oodle-managed-code-token-latency-budget-v1` | The call stays inside its token, duration and time to first token limits | `within_budget`, `budget_used` | [`token_latency_budget`](./oodle-eval-reference#metrics-token_latency_budget) | `list_templates` in MCP and `oodle genai templates list` in the CLI show these templates with the other managed templates. ## When you need more - **Change a check's logic.** Start a code template from the check's starter (the template form offers each check as a starter), then change the code. - **Run several checks in one evaluator, or add your own score.** Call the library functions from your own template. See the [cookbook](./code-evaluator-cookbook). - **Compare with a value from the span.** Call a reference check from your own template and pass `reference=` from `ctx`. See the [cookbook](./code-evaluator-cookbook#compare-with-a-reference-value). Built-in checks need the same account access as other code evaluators. --- ## Code Evaluator Cookbook Each recipe is a complete code evaluator. Paste it into a new code template, click **Run** on a sample span, and save it. The functions come from the [`oodle_eval` library](./oodle-eval-reference). ## Built-in check plus your own score Run a built-in check and add a score of your own. Each library check returns a list of scores, so add your score to the list. This is the same evaluator as an Opik custom metric that runs `ConversationDegenerationMetric` and adds its own score: ```python from oodle_eval.v1 import metrics, text def evaluate(ctx): # degenerated (BOOLEAN) and degeneration (0 to 1) scores = metrics.conversation_degeneration(ctx, threshold=0.6) reply = text.reply(ctx) mine = Score( name="mentions_refund", value="refund" in reply.lower(), data_type="BOOLEAN", higher_is_better=True, ) return EvaluationResult(scores=scores + [mine]) ``` ```python from opik.evaluation.metrics import ConversationDegenerationMetric from opik.evaluation.metrics import base_metric, score_result class SupportReply(base_metric.BaseMetric): def __init__(self, name="support_reply"): super().__init__(name=name) self.degeneration = ConversationDegenerationMetric() def score(self, conversation, **ignored_kwargs): degeneration = self.degeneration.score(conversation=conversation) reply = conversation[-1]["content"] mine = score_result.ScoreResult( name="mentions_refund", value=float("refund" in reply.lower()), ) return [degeneration, mine] ``` In Oodle, the evaluator runs on each chat span as it arrives. `ctx` holds the conversation: `text.turns(ctx)` gives the turns as `{"role", "content"}` dicts, and `text.reply(ctx)` gives the newest reply. ## Weighted quality score Run several checks and make one 0 to 1 score from them. Keep the separate scores too, so a drop in the combined score shows its cause. ```python from oodle_eval.v1 import combine, metrics def evaluate(ctx): readable = metrics.readability(ctx, max_grade=10) tone = metrics.tone(ctx) length = metrics.length_budget(ctx, max_words=250) # The primary score of each check: all BOOLEAN, so all 0 or 1. primary = [readable["readability_ok"], tone["tone_ok"], length["within_length"]] quality = combine.weighted_mean( primary, weights={"readability_ok": 1, "tone_ok": 2, "within_length": 1}, name="quality", ) return EvaluationResult(scores=readable + tone + length + [quality]) ``` `weighted_mean` reads a `BOOLEAN` score as 0 or 1, and skips categorical scores and scores with no value. It uses each value as it is, so give it scores on the same scale that read the same way. Here that excludes `reading_ease`, which is 0 to 100. A name that `weights` does not list has the weight 1. ## Gate on a check Score the content of a JSON reply only when the reply is valid JSON. When it is not, return the JSON check alone: a content score for a reply that did not parse means nothing. ```python from oodle_eval.v1 import combine, metrics, text def evaluate(ctx): shape = metrics.json_validity(ctx, required_keys=["answer", "sources"]) if not combine.passed(shape["is_json"]): return EvaluationResult(scores=shape) data = text.extract_json(text.reply(ctx)) cited = Score( name="has_sources", value=isinstance(data, dict) and bool(data.get("sources")), data_type="BOOLEAN", higher_is_better=True, ) return EvaluationResult(scores=shape + [cited]) ``` To gate a **different evaluator** on this one, for example an LLM judge that must run only on valid replies, use an [evaluator dependency](./dependencies). ## All checks pass Return one pass or fail score for a set of rules, plus the separate scores: ```python from oodle_eval.v1 import combine, metrics def evaluate(ctx): checks = ( metrics.pii_leak(ctx) + metrics.refusal(ctx) + metrics.keyword_check(ctx, forbidden=["guarantee", "promise"]) ) return EvaluationResult(scores=checks + [combine.all_pass(checks, name="safe_reply")]) ``` `all_pass` reads each score with `combine.passed`, in the direction that the score declares. The `refused` score is lower-is-better, so a reply that refuses fails the set. A numeric score passes at 0.5 or more (below 0.5 for a lower-is-better score), unless you give `at=`. ## Tool trajectory Check that an agent called the tools that you expect. In an experiment, `ctx.trace.spans` holds the agent's steps. On a live span, the check reads the tool calls in the span's output. ```python from oodle_eval.v1 import metrics def evaluate(ctx): return EvaluationResult(scores=metrics.tool_trajectory( ctx, expected=["search_orders", "issue_refund"], mode="superset", )) ``` | `mode` | Passes when | |--------|-------------| | `strict` | The same calls in the same order | | `unordered` | The same calls in any order | | `subset` | Every call is an expected one: no extra tool | | `superset` | Every expected call happened. Extra calls are allowed | Make `expected` a [setting](./code-evaluator-settings) to use one template for many agents: ```python from oodle_eval.v1 import metrics def evaluate(ctx): return EvaluationResult(scores=metrics.tool_trajectory(ctx, **ctx.params)) ``` ## Use a shared library Put your team's rules in a [shared library](./shared-libraries) and import them from each evaluator. This library holds a banned-phrase list and a check that uses it: ```python # shared library "acme_policy" from oodle_eval.v1 import metrics BANNED = ["guarantee", "risk-free", "cannot lose"] def policy_check(ctx): return metrics.keyword_check(ctx, forbidden=BANNED, name="policy_ok") ``` ```python # code evaluator from shared.acme_policy import policy_check from oodle_eval.v1 import metrics def evaluate(ctx): return EvaluationResult(scores=policy_check(ctx) + metrics.pii_leak(ctx)) ``` When you change `BANNED`, every evaluator that imports `acme_policy` uses the new list, unless its template pins an earlier version. ## Combine an LLM judge with a code check Read the score of an LLM-as-Judge evaluator and a code check that run on the same span, and write one overall score. The names are evaluator names from the Evaluators tab. Oodle runs this evaluator after both of them. See [Combine Scores from Other Evaluators](./combine-evaluator-scores). ```python from oodle_eval.v1 import combine def evaluate(ctx): relevance = ctx.score("Answer relevance", default=0.0) pii_free = ctx.score("PII leak", "pii_free", default=True) overall = combine.weighted_mean( [ Score(name="relevance", value=relevance), Score(name="pii_free", value=pii_free, data_type="BOOLEAN"), ], weights={"relevance": 3, "pii_free": 1}, name="overall", ) return EvaluationResult(scores=[overall]) ``` ## Compare with a reference value In an experiment, the checks that compare with a reference use the dataset item's expected output (`ctx.expected_output`) when you do not pass `reference`. On a live span there is no expected output: give the reference as a setting, or read it from a span attribute. ```python from oodle_eval.v1 import metrics, text def evaluate(ctx): # The expected output in an experiment, else a span attribute. expected = ctx.expected_output if expected is None: expected = text.metadata_value(ctx, "app.expected_answer", default="") return EvaluationResult(scores=( metrics.exact_match(ctx, reference=expected, normalize=True) + metrics.levenshtein_ratio(ctx, reference=expected) )) ``` ## A RAG answer is grounded in its context Check that a retrieval-augmented answer stays inside what was retrieved: its sentences use the context's words, its numbers and names are in the context, and its citations point at real sources. Then make one pass or fail score. ```python from oodle_eval.v1 import combine, metrics def evaluate(ctx): grounded = metrics.grounding(ctx, threshold=0.8) numbers = metrics.unsupported_numbers(ctx) citations = metrics.citation_check(ctx) checks = grounded + numbers + citations primary = [ grounded["grounded"], numbers["numbers_supported"], citations["citations_ok"], ] return EvaluationResult(scores=checks + [combine.all_pass(primary, name="rag_ok")]) ``` The three checks find the retrieved context in this order: 1. The `context` argument, when you pass one. 2. The span attribute that `context_key` names (`context` by default). Pass `context_key=` when your application writes the context to another attribute. 3. The text of the input messages: a RAG call usually puts what it retrieved in its prompt. --- ## Code Evaluator Settings A code template can declare **settings**: named values that its code reads from `ctx.params`. Each evaluator that runs the template sets its own values. One template then serves many evaluators, and you change a keyword list or a threshold without a copy of the code. For example, one "Keyword check" template can run as two evaluators: one that requires `refund` on support replies, and one that forbids `guarantee` on sales replies. ## Declare settings on a template In the code template form, add a row for each setting to the **Settings** table below the code. Through the API, the CLI or MCP, send them as the template's `params` list: ```json { "name": "Keyword check", "type": "code", "sourceCode": "...", "params": [ { "name": "required", "type": "string_list", "label": "Required keywords", "description": "The reply must mention each of these.", "default": [] }, { "name": "mode", "type": "enum", "options": ["all", "any"], "default": "all" }, { "name": "case_sensitive", "type": "boolean", "default": false } ] } ``` | Field | Required | Description | |-------|----------|-------------| | `name` | yes | The key in `ctx.params`. A lower-case Python name: letters, digits and `_`, up to 64 characters, not starting with a digit | | `type` | yes | One of the types below | | `label` | no | The label on the evaluator form | | `description` | no | Help text on the evaluator form | | `default` | no | The value when an evaluator does not set one. It must have the setting's type | | `required` | no | `true` makes each evaluator set a value, unless there is a default | | `options` | for `enum` | The values an `enum` setting can have | A template can declare up to 32 settings, and each name only once. ### Types | Type | Value in `ctx.params` | Form input | |------|------------------------|------------| | `string` | `str` | One line of text | | `text` | `str` | Several lines of text | | `number` | `int` or `float` | Number | | `integer` | `int` (a whole number) | Number | | `boolean` | `True` or `False` | Switch | | `string_list` | `list` of `str` | One input for each value. A value can contain commas | | `enum` | `str`, one of `options` | Drop-down | | `json` | Any JSON value (dict, list, number, string) | JSON editor | ## Set values on an evaluator When you create or edit an evaluator on a template with settings, the form shows one input for each setting. Through the API, the CLI or MCP, send the values as the evaluator's `params` object: ```json { "name": "Support replies mention refunds", "evaluatorId": "