Skip to content

Commit 0dddded

Browse files
Merge pull request #431 from piochelepiotr/piotr.wolski/data-streams-experimental
feat(data-streams): experimental DSM Kafka commands
2 parents 7b270d0 + 9f4e345 commit 0dddded

5 files changed

Lines changed: 478 additions & 0 deletions

File tree

agents/kafka.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
---
2+
description: Inspect Kafka topics/brokers, schema registry, client configs, and on-demand read of Kafka messages. Auto-discovers cluster ID, bootstrap servers, latest produced offset, and consumer-group offsets/lag from Datadog metrics before invoking reads.
3+
---
4+
5+
# Kafka Agent
6+
7+
You are a specialized agent for inspecting Kafka clusters through Datadog via the `pup` CLI. Your job is to help the user inspect Kafka clusters, topics, brokers, schemas, and — when needed — read live messages from Kafka via the Datadog Agent.
8+
9+
## Important Context
10+
11+
**CLI Tool**: This agent uses the `pup` CLI to execute Datadog API commands.
12+
13+
**Environment Variables**:
14+
- `DD_API_KEY` / `DD_APP_KEY`: Required if you are not using OAuth2 (`pup auth login`). Note the `read-messages` and `client-configs` endpoints currently require an OAuth2 bearer (UI session); API/APP key auth is rejected today.
15+
- `DD_SITE`: Datadog site. Default `datadoghq.com`. Use `datad0g.com` for staging.
16+
17+
**API surface**: these commands hit experimental Datadog routes that are **not** part of the public API contract and may change.
18+
19+
## Permission Model
20+
21+
`read-messages` requires the `data_streams_capture_messages` permission and is rate-limited to 10 calls/minute per user.
22+
23+
## Available Commands
24+
25+
```bash
26+
# Topic config history
27+
pup kafka topic-configs \
28+
--kafka-cluster-id <id> --topic <topic>
29+
30+
# Broker config history
31+
pup kafka broker-configs \
32+
--kafka-cluster-id <id> --broker-id <broker>
33+
34+
# Producer/consumer client configs (one or more service:type pairs)
35+
pup kafka client-configs \
36+
--kafka-cluster-id <id> \
37+
--service <svc>:producer \
38+
--service <svc>:consumer
39+
40+
# Schema registry — full version history of a subject on a cluster
41+
pup kafka subject-schemas \
42+
--kafka-cluster-id <id> --subject <subject>
43+
44+
# Read live messages (rate-limited, agent-mediated)
45+
pup kafka read-messages \
46+
--cluster <id> --topic <topic> \
47+
--bootstrap-servers <host:port,...> \
48+
[--partition N] [--start-offset N] [--start-timestamp ms] \
49+
[--n-messages-retrieved N] [--max-scanned-messages N] \
50+
[--filter expr] [--consumer-group-id <id>]
51+
```
52+
53+
### `--filter` expressions
54+
55+
`--filter` is a jq-style expression evaluated agent-side against each deserialized message. The message context exposes top-level fields `.key`, `.value`, `.headers`, `.topic`, `.partition`, `.offset`, and `.timestamp`; navigate nested fields with dotted paths (e.g. `.value.user.country`).
56+
57+
- Operators: `==`, `!=`, `>`, `<`, `>=`, `<=`, `contains`.
58+
- Combine with ` and ` / ` or ` (note: `or` has higher precedence — it is split first).
59+
- String literals must be quoted with `"` or `'`. Numeric literals are parsed as int/float.
60+
- A bare path (no operator) is an existence check — true when the field resolves to a non-null value.
61+
62+
Examples:
63+
64+
```bash
65+
--filter='.value.status == "failed"'
66+
--filter='.value.amount > 100'
67+
--filter='.headers.tenant == "acme" and .value.priority >= 5'
68+
--filter='.value.tags contains "urgent"'
69+
--filter='.value.error' # existence
70+
```
71+
72+
## Auto-discovering arguments via Datadog metrics
73+
74+
Before calling `read-messages`, you almost never have the `kafka_cluster_id` / `bootstrap_servers` / partition / offset on hand. Resolve them by querying Datadog metrics with `pup metrics query`. **These tools are usable only when `kafka.broker.count` is reported for the cluster** — if that metric is empty, do not call `read-messages`.
75+
76+
The relevant metrics (all share the same tag set: `kafka_cluster_id`, `bootstrap_servers`, `topic`, `partition`, and for consumer-group metrics `consumer_group`):
77+
78+
| Metric | What it tells you |
79+
|---|---|
80+
| `kafka.broker.count` | Known clusters. Tags: `kafka_cluster_id`, `bootstrap_servers`. |
81+
| `kafka.broker_offset` | Latest produced offset per partition. Tags: `kafka_cluster_id`, `topic`, `partition`. |
82+
| `kafka.consumer_offset` | Last committed offset of a consumer group. Tags: `kafka_cluster_id`, `topic`, `partition`, `consumer_group`. |
83+
| `kafka.consumer_lag` | Consumer lag in offsets. Same tags. |
84+
| `kafka.estimated_consumer_lag` | Consumer lag in seconds. Same tags. |
85+
86+
### Resolution recipes
87+
88+
**1. Resolve `kafka_cluster_id` + `bootstrap_servers` from a topic name:**
89+
```bash
90+
pup metrics query \
91+
--query='max:kafka.broker.count{topic:<TOPIC>} by {kafka_cluster_id,bootstrap_servers}' \
92+
--from='now-15m'
93+
```
94+
The single (or top) returned series's tag values are your `--cluster` and `--bootstrap-servers`.
95+
96+
**2. Find the partition with the most data, and the latest produced offset:**
97+
```bash
98+
pup metrics query \
99+
--query='max:kafka.broker_offset{topic:<TOPIC>} by {partition}' \
100+
--from='now-15m'
101+
```
102+
Pick the partition with the highest value. For a tail read use `--start-offset = max - n_messages_retrieved`.
103+
104+
**3. Tail what a consumer hasn't yet processed:**
105+
```bash
106+
pup metrics query \
107+
--query='max:kafka.consumer_offset{topic:<TOPIC>,consumer_group:<GROUP>} by {partition}' \
108+
--from='now-15m'
109+
```
110+
Use that value as `--start-offset` and pass `--consumer-group-id <GROUP>`. Pair with `kafka.consumer_lag` (offsets) or `kafka.estimated_consumer_lag` (seconds) to report how far behind the consumer is.
111+
112+
If a query returns no series, surface that to the user instead of guessing.
113+
114+
## Worked example
115+
116+
> "Get the last 5 messages on topic `orders-events`."
117+
118+
1. Resolve cluster + bootstrap:
119+
```bash
120+
pup metrics query \
121+
--query='max:kafka.broker.count{topic:orders-events} by {kafka_cluster_id,bootstrap_servers}' \
122+
--from='now-15m'
123+
```
124+
2. Find the busiest partition and its latest offset:
125+
```bash
126+
pup metrics query \
127+
--query='max:kafka.broker_offset{topic:orders-events} by {partition}' \
128+
--from='now-15m'
129+
```
130+
3. Confirm with the user, then read:
131+
```bash
132+
pup kafka read-messages \
133+
--cluster <id-from-step-1> \
134+
--topic orders-events \
135+
--bootstrap-servers <bootstrap-from-step-1> \
136+
--partition <p-from-step-2> \
137+
--start-offset <max-5> \
138+
--n-messages-retrieved 5
139+
```
140+
141+
## Failure modes
142+
143+
- **`kafka.broker.count` returns no data** — the cluster is not reporting Kafka telemetry to Datadog; `read-messages` will hang or fail. Stop and tell the user.
144+
- **`HTTP 403 / data_streams_capture_messages`** — the caller lacks the permission. Ask the user to request it; do not retry.
145+
- **`HTTP 504` / no response** — no Datadog Agent reachable by Remote Config can connect to the cluster. The cluster may be air-gapped or the Agent isn't deployed where it can see the brokers.

src/client.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -862,6 +862,37 @@ fn apply_auth(
862862
anyhow::bail!("no authentication configured")
863863
}
864864

865+
/// POST a JSON:API document. Wraps `attributes` in `{data:{type,attributes}}`
866+
/// and sends with `Content-Type: application/vnd.api+json`. Use for routes
867+
/// whose decoder is configured for JSON:API.
868+
pub async fn raw_post_jsonapi(
869+
cfg: &Config,
870+
path: &str,
871+
resource_type: &str,
872+
attributes: serde_json::Value,
873+
) -> anyhow::Result<serde_json::Value> {
874+
let url = format!("{}{}", cfg.api_base_url(), path);
875+
let envelope = serde_json::json!({
876+
"data": { "type": resource_type, "attributes": attributes },
877+
});
878+
let client = reqwest::Client::new();
879+
let mut req = client.post(&url);
880+
req = apply_auth(req, cfg, "POST", path)?;
881+
let resp = req
882+
.header("Content-Type", "application/vnd.api+json")
883+
.header("Accept", "application/vnd.api+json")
884+
.header("User-Agent", useragent::get())
885+
.json(&envelope)
886+
.send()
887+
.await?;
888+
if !resp.status().is_success() {
889+
let status = resp.status();
890+
let body = resp.text().await.unwrap_or_default();
891+
anyhow::bail!("POST {url} failed (HTTP {status}): {body}");
892+
}
893+
Ok(resp.json().await?)
894+
}
895+
865896
/// Like `raw_post`, but returns the parsed JSON body even on non-2xx responses.
866897
/// Callers are responsible for inspecting the body for errors.
867898
pub async fn raw_post_lenient(

src/commands/kafka.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//! [Experimental] Kafka inspection commands.
2+
//!
3+
//! These endpoints are experimental — the API surface is not covered by
4+
//! Datadog's public API compatibility guarantees and may change without
5+
//! notice.
6+
//!
7+
//! Authentication: OAuth2 bearer token (e.g. `pup auth login`). DD_API_KEY +
8+
//! DD_APP_KEY is not accepted by these UI routes.
9+
10+
use anyhow::Result;
11+
use serde_json::{json, Value};
12+
13+
use crate::client;
14+
use crate::config::Config;
15+
use crate::formatter;
16+
17+
const TOPIC_CONFIGS_PATH: &str = "/api/ui/data_streams/kafka_topic_configs";
18+
const BROKER_CONFIGS_PATH: &str = "/api/ui/data_streams/kafka_broker_configs";
19+
const CLIENT_CONFIGS_PATH: &str = "/api/ui/data_streams/kafka_client_configs";
20+
const READ_MESSAGES_PATH: &str = "/api/ui/data_streams/kafka_actions/read_messages";
21+
const SUBJECT_SCHEMAS_PATH: &str = "/api/ui/data_streams/subject_kafka_schemas";
22+
23+
pub async fn topic_configs(cfg: &Config, kafka_cluster_id: &str, topic: &str) -> Result<()> {
24+
let query = [("kafka_cluster_id", kafka_cluster_id), ("topic", topic)];
25+
let resp = client::raw_get(cfg, TOPIC_CONFIGS_PATH, &query)
26+
.await
27+
.map_err(|e| anyhow::anyhow!("failed to get kafka topic configs: {e:?}"))?;
28+
formatter::output(cfg, &resp)
29+
}
30+
31+
pub async fn broker_configs(cfg: &Config, kafka_cluster_id: &str, broker_id: &str) -> Result<()> {
32+
let query = [
33+
("kafka_cluster_id", kafka_cluster_id),
34+
("broker_id", broker_id),
35+
];
36+
let resp = client::raw_get(cfg, BROKER_CONFIGS_PATH, &query)
37+
.await
38+
.map_err(|e| anyhow::anyhow!("failed to get kafka broker configs: {e:?}"))?;
39+
formatter::output(cfg, &resp)
40+
}
41+
42+
/// `services` is a list of `service:config_type` pairs where `config_type` is
43+
/// either `producer` or `consumer`.
44+
pub async fn client_configs(
45+
cfg: &Config,
46+
kafka_cluster_id: &str,
47+
services: Vec<(String, String)>,
48+
) -> Result<()> {
49+
if services.is_empty() {
50+
anyhow::bail!("at least one --service SERVICE:producer|consumer is required");
51+
}
52+
let services_json: Vec<Value> = services
53+
.into_iter()
54+
.map(|(service, config_type)| json!({ "service": service, "config_type": config_type }))
55+
.collect();
56+
let body = json!({
57+
"kafka_cluster_id": kafka_cluster_id,
58+
"services": services_json,
59+
});
60+
let resp = client::raw_post(cfg, CLIENT_CONFIGS_PATH, body)
61+
.await
62+
.map_err(|e| anyhow::anyhow!("failed to get kafka client configs: {e:?}"))?;
63+
formatter::output(cfg, &resp)
64+
}
65+
66+
/// Dispatches a Kafka read-messages action to the agent via Remote Config and
67+
/// polls until the agent responds.
68+
#[allow(clippy::too_many_arguments)]
69+
pub async fn read_messages(
70+
cfg: &Config,
71+
cluster: &str,
72+
topic: &str,
73+
bootstrap_servers: &str,
74+
partition: Option<i32>,
75+
start_offset: i64,
76+
start_timestamp: Option<i64>,
77+
n_messages_retrieved: u32,
78+
max_scanned_messages: u32,
79+
filter: Option<String>,
80+
consumer_group_id: Option<String>,
81+
) -> Result<()> {
82+
let mut attrs = json!({
83+
"cluster": cluster,
84+
"topic": topic,
85+
"bootstrap_servers": bootstrap_servers,
86+
"start_offset": start_offset,
87+
"n_messages_retrieved": n_messages_retrieved,
88+
"max_scanned_messages": max_scanned_messages,
89+
});
90+
if let Some(p) = partition {
91+
attrs["partition"] = json!(p);
92+
}
93+
if let Some(ts) = start_timestamp {
94+
attrs["start_timestamp"] = json!(ts);
95+
}
96+
if let Some(f) = filter {
97+
attrs["filter"] = json!(f);
98+
}
99+
if let Some(cg) = consumer_group_id {
100+
attrs["consumer_group_id"] = json!(cg);
101+
}
102+
103+
let resp =
104+
client::raw_post_jsonapi(cfg, READ_MESSAGES_PATH, "kafka_action_read_messages", attrs)
105+
.await
106+
.map_err(|e| anyhow::anyhow!("failed to read kafka messages: {e:?}"))?;
107+
formatter::output(cfg, &resp)
108+
}
109+
110+
/// All version history of a single Schema Registry subject on a Kafka cluster.
111+
pub async fn subject_schemas(cfg: &Config, kafka_cluster_id: &str, subject: &str) -> Result<()> {
112+
let query = [("kafka_cluster_id", kafka_cluster_id), ("subject", subject)];
113+
let resp = client::raw_get(cfg, SUBJECT_SCHEMAS_PATH, &query)
114+
.await
115+
.map_err(|e| anyhow::anyhow!("failed to get subject kafka schemas: {e:?}"))?;
116+
formatter::output(cfg, &resp)
117+
}

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ pub mod incidents;
4545
pub mod infrastructure;
4646
pub mod integrations;
4747
pub mod investigations;
48+
pub mod kafka;
4849
pub mod llm_obs;
4950
pub mod logs;
5051
pub mod logs_restriction;

0 commit comments

Comments
 (0)