@dreki-gg/pi-datadog 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +46 -3
- package/extensions/datadog/client.ts +94 -2
- package/extensions/datadog/config.ts +20 -0
- package/extensions/datadog/dotenv.ts +85 -0
- package/extensions/datadog/format.ts +59 -6
- package/extensions/datadog/index.ts +202 -8
- package/extensions/datadog/output.ts +20 -0
- package/extensions/datadog/rum-client.ts +130 -0
- package/extensions/datadog/rum-format.ts +188 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# @dreki-gg/pi-datadog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add `datadog_rum_search` tool for searching Datadog RUM events (sessions, views, actions, front-end errors). Defaults to session events and, like the logs tool, returns a compact inline digest while writing the full untruncated events to a temp file the agent can read. Adds optional `rumApplicationId` / `rumService` fields to `.pi/datadog.json` (with per-call overrides), and surfaces them in the `/datadog` status command.
|
|
8
|
+
|
|
9
|
+
## 0.2.4
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Return a compact digest inline and write full log entries to a temp file.
|
|
14
|
+
|
|
15
|
+
`datadog_logs_search` no longer dumps truncated log entries inline. Instead it returns a token-light digest (result count, status breakdown, services, pagination) plus the path to a temp file containing the complete, untruncated results (full messages and attributes). The agent reads that file with the `read` tool when it needs actual log content — saving context tokens, removing the truncation blind spot, and avoiding extra rate-limited re-queries. Tool guidelines were updated to point at the file.
|
|
16
|
+
|
|
17
|
+
## 0.2.3
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Write full untruncated log search results to a temp file the agent can read.
|
|
22
|
+
|
|
23
|
+
The inline `datadog_logs_search` output still truncates long messages and attributes for brevity, but the complete, untruncated results are now also written to a temp file and the path is included in the response. When the agent needs full message content (status codes, paths, stack traces), it can `read` that file instead of re-querying Datadog — avoiding both the truncation blind spot and extra rate-limited requests. Tool guidelines were updated to point at the file.
|
|
24
|
+
|
|
25
|
+
## 0.2.2
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- Handle Datadog rate limits gracefully instead of failing on 429s.
|
|
30
|
+
|
|
31
|
+
The log search client now enables the Datadog SDK's built-in retry (up to 4 attempts on 429/5xx), which honours the `x-ratelimit-reset` header and waits exactly the window Datadog asks for before retrying. When retries are exhausted, the tool returns a clear, actionable message (distinguishing rate-limit, auth, and other API errors) telling the agent to back off and keep queries narrow/batched rather than firing again immediately. Tool guidelines were updated to reinforce this batching behaviour.
|
|
32
|
+
|
|
33
|
+
## 0.2.1
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- Load project-root `.env` files so `DD_API_KEY` / `DD_APP_KEY` defined there are picked up.
|
|
38
|
+
|
|
39
|
+
Previously credentials were only read from the process environment, so keys placed in a project's `.env` (without exporting them in the shell) were never found and the extension reported missing credentials. The extension now loads `<cwd>/.env` via Node's built-in `process.loadEnvFile` at session start, before the tool runs, and in the `/datadog` status command. Shell-exported variables still take precedence over `.env` values.
|
|
40
|
+
|
|
3
41
|
## 0.2.0
|
|
4
42
|
|
|
5
43
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @dreki-gg/pi-datadog
|
|
2
2
|
|
|
3
|
-
Datadog log search tools for [pi](https://github.com/earendil-works/pi) — query production logs with project-aware context.
|
|
3
|
+
Datadog log and RUM search tools for [pi](https://github.com/earendil-works/pi) — query production logs and Real User Monitoring sessions with project-aware context.
|
|
4
4
|
|
|
5
5
|
## Setup
|
|
6
6
|
|
|
@@ -33,7 +33,9 @@ Create `.pi/datadog.json` in your project root to set defaults:
|
|
|
33
33
|
"env": "production",
|
|
34
34
|
"site": "datadoghq.com",
|
|
35
35
|
"defaultTags": ["team:backend"],
|
|
36
|
-
"defaultTimeRange": "1h"
|
|
36
|
+
"defaultTimeRange": "1h",
|
|
37
|
+
"rumApplicationId": "abcd-1234",
|
|
38
|
+
"rumService": "web-frontend"
|
|
37
39
|
}
|
|
38
40
|
```
|
|
39
41
|
|
|
@@ -44,6 +46,8 @@ Create `.pi/datadog.json` in your project root to set defaults:
|
|
|
44
46
|
| `site` | Datadog site | `datadoghq.com` |
|
|
45
47
|
| `defaultTags` | Tags auto-appended to every query | `[]` |
|
|
46
48
|
| `defaultTimeRange` | Default lookback window | `1h` |
|
|
49
|
+
| `rumApplicationId` | Default RUM application id (`@application.id`) for RUM searches | _(none)_ |
|
|
50
|
+
| `rumService` | Default service for RUM searches (falls back to `service`) | _(none)_ |
|
|
47
51
|
|
|
48
52
|
**Supported sites:** `datadoghq.com` (US1), `us3.datadoghq.com`, `us5.datadoghq.com`, `datadoghq.eu` (EU).
|
|
49
53
|
|
|
@@ -61,6 +65,14 @@ Just ask pi to search your logs:
|
|
|
61
65
|
|
|
62
66
|
The agent uses your `.pi/datadog.json` defaults automatically — you don't need to specify service or environment unless you want to override them.
|
|
63
67
|
|
|
68
|
+
You can also ask about front-end / RUM sessions:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
> Show me RUM sessions from the last hour
|
|
72
|
+
> Find RUM errors on the checkout view in production
|
|
73
|
+
> What views did users hit in the last 30 minutes?
|
|
74
|
+
```
|
|
75
|
+
|
|
64
76
|
### Tool: `datadog_logs_search`
|
|
65
77
|
|
|
66
78
|
The agent calls this tool with [Datadog query syntax](https://docs.datadoghq.com/logs/explorer/search_syntax/):
|
|
@@ -75,6 +87,25 @@ The agent calls this tool with [Datadog query syntax](https://docs.datadoghq.com
|
|
|
75
87
|
| `service` | `string` | Override project default service |
|
|
76
88
|
| `env` | `string` | Override project default environment |
|
|
77
89
|
|
|
90
|
+
The agent receives a compact inline digest (counts, status breakdown, services). The full, untruncated log entries are written to a temp file whose path is in the response — the agent reads that file with the `read` tool to inspect actual content.
|
|
91
|
+
|
|
92
|
+
### Tool: `datadog_rum_search`
|
|
93
|
+
|
|
94
|
+
Searches Datadog RUM events (sessions, views, actions, front-end errors) with [RUM query syntax](https://docs.datadoghq.com/real_user_monitoring/explorer/search_syntax/). Defaults to **session** events (`@type:session`) when the query omits `@type`.
|
|
95
|
+
|
|
96
|
+
| Parameter | Type | Description |
|
|
97
|
+
|-----------|------|-------------|
|
|
98
|
+
| `query` | `string` | **Required.** RUM query (e.g. `@type:session`, `@type:error`, `@view.url_path:/checkout`). Leave empty to list recent sessions |
|
|
99
|
+
| `from` | `string` | Start time — relative (`15m`, `1h`, `7d`) or ISO 8601 |
|
|
100
|
+
| `to` | `string` | End time — relative, ISO 8601, or `now` |
|
|
101
|
+
| `limit` | `number` | Max results (1–100, default 25) |
|
|
102
|
+
| `sort` | `string` | `newest` or `oldest` |
|
|
103
|
+
| `service` | `string` | Override project default RUM service |
|
|
104
|
+
| `env` | `string` | Override project default environment |
|
|
105
|
+
| `applicationId` | `string` | Override project default RUM application id (`@application.id`) |
|
|
106
|
+
|
|
107
|
+
Like the logs tool, it returns a compact digest inline (counts, event-type breakdown, services) and writes the full events to a `rum-*.md` temp file the agent can `read`.
|
|
108
|
+
|
|
78
109
|
### Command: `/datadog`
|
|
79
110
|
|
|
80
111
|
Check your configuration and connection status:
|
|
@@ -83,7 +114,7 @@ Check your configuration and connection status:
|
|
|
83
114
|
/datadog
|
|
84
115
|
```
|
|
85
116
|
|
|
86
|
-
Shows: credential status (set/missing), project config (service, env, site, tags).
|
|
117
|
+
Shows: credential status (set/missing), project config (service, env, site, tags, RUM application, RUM service).
|
|
87
118
|
|
|
88
119
|
## Query Syntax Examples
|
|
89
120
|
|
|
@@ -97,6 +128,18 @@ service:my-api env:production @duration:>5000 # Slow requests
|
|
|
97
128
|
|
|
98
129
|
See the [Datadog Log Search Syntax docs](https://docs.datadoghq.com/logs/explorer/search_syntax/) for the full reference.
|
|
99
130
|
|
|
131
|
+
### RUM query examples
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
@type:session # User sessions (the default)
|
|
135
|
+
@type:error # Front-end errors
|
|
136
|
+
@type:view @view.url_path:/checkout # Views on the checkout page
|
|
137
|
+
@type:session @session.type:user # Real user sessions (exclude synthetics)
|
|
138
|
+
@application.id:abcd-1234 @type:action # Actions in a specific RUM application
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
See the [RUM Search Syntax docs](https://docs.datadoghq.com/real_user_monitoring/explorer/search_syntax/) for the full reference.
|
|
142
|
+
|
|
100
143
|
## How It Works
|
|
101
144
|
|
|
102
145
|
1. The extension loads `.pi/datadog.json` from your project on session start
|
|
@@ -98,16 +98,108 @@ export function buildQuery(params: LogSearchParams, config: DatadogProjectConfig
|
|
|
98
98
|
return parts.join(' ');
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
/** Max automatic retry attempts on 429 / 5xx responses. */
|
|
102
|
+
const MAX_RETRIES = 4;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Builds a Datadog SDK configuration with credentials, the target site, and
|
|
106
|
+
* transparent retry on 429 / 5xx. The SDK honours the `x-ratelimit-reset`
|
|
107
|
+
* header, so it waits exactly the window Datadog asks for before retrying
|
|
108
|
+
* (falling back to exponential backoff otherwise). This is what keeps the
|
|
109
|
+
* tools from "crying" about rate limits on bursty usage.
|
|
110
|
+
*
|
|
111
|
+
* Shared by the logs and RUM clients so both inherit identical retry behaviour.
|
|
112
|
+
*/
|
|
113
|
+
export function createConfiguration(credentials: DatadogCredentials, site: string) {
|
|
102
114
|
const configuration = client.createConfiguration({
|
|
103
115
|
authMethods: {
|
|
104
116
|
apiKeyAuth: credentials.apiKey,
|
|
105
117
|
appKeyAuth: credentials.appKey,
|
|
106
118
|
},
|
|
119
|
+
enableRetry: true,
|
|
120
|
+
maxRetries: MAX_RETRIES,
|
|
107
121
|
});
|
|
108
122
|
configuration.setServerVariables({ site });
|
|
123
|
+
return configuration;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createApiInstance(credentials: DatadogCredentials, site: string): v2.LogsApi {
|
|
127
|
+
return new v2.LogsApi(createConfiguration(credentials, site));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface DatadogErrorInfo {
|
|
131
|
+
/** Human-readable, agent-facing message. */
|
|
132
|
+
message: string;
|
|
133
|
+
/** HTTP status code, when the error came from the Datadog API. */
|
|
134
|
+
code?: number;
|
|
135
|
+
/** True when the error is a rate-limit (429) after retries were exhausted. */
|
|
136
|
+
isRateLimit: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Classifies an error thrown by the Datadog SDK into a clear, actionable message.
|
|
141
|
+
*
|
|
142
|
+
* The SDK throws an `ApiException`-shaped object (`{ code, body }`) on non-2xx
|
|
143
|
+
* responses. A 429 here means retries were already exhausted, so the message
|
|
144
|
+
* tells the agent to back off and keep queries narrow/batched rather than
|
|
145
|
+
* firing more requests.
|
|
146
|
+
*/
|
|
147
|
+
export function describeDatadogError(err: unknown): DatadogErrorInfo {
|
|
148
|
+
const code = extractStatusCode(err);
|
|
149
|
+
const detail = extractApiMessage(err);
|
|
150
|
+
|
|
151
|
+
if (code === 429) {
|
|
152
|
+
return {
|
|
153
|
+
code,
|
|
154
|
+
isRateLimit: true,
|
|
155
|
+
message:
|
|
156
|
+
`Rate limited by Datadog (429) after ${MAX_RETRIES} retries${detail ? `: ${detail}` : ''}. ` +
|
|
157
|
+
'Wait before retrying, and keep queries narrow/batched instead of firing many in quick succession.',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
109
160
|
|
|
110
|
-
|
|
161
|
+
if (code === 401 || code === 403) {
|
|
162
|
+
return {
|
|
163
|
+
code,
|
|
164
|
+
isRateLimit: false,
|
|
165
|
+
message: `Datadog auth error (${code})${detail ? `: ${detail}` : ''}. Check DD_API_KEY / DD_APP_KEY.`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (code !== undefined) {
|
|
170
|
+
return {
|
|
171
|
+
code,
|
|
172
|
+
isRateLimit: false,
|
|
173
|
+
message: `Datadog API error (${code})${detail ? `: ${detail}` : ''}.`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
isRateLimit: false,
|
|
179
|
+
message: `Datadog API error: ${err instanceof Error ? err.message : String(err)}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function extractStatusCode(err: unknown): number | undefined {
|
|
184
|
+
if (typeof err === 'object' && err !== null && 'code' in err) {
|
|
185
|
+
const code = (err as { code: unknown }).code;
|
|
186
|
+
if (typeof code === 'number') return code;
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function extractApiMessage(err: unknown): string | undefined {
|
|
192
|
+
if (typeof err !== 'object' || err === null || !('body' in err)) return undefined;
|
|
193
|
+
const body = (err as { body: unknown }).body;
|
|
194
|
+
|
|
195
|
+
if (typeof body === 'string') return body || undefined;
|
|
196
|
+
if (typeof body === 'object' && body !== null && 'errors' in body) {
|
|
197
|
+
const errors = (body as { errors: unknown }).errors;
|
|
198
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
199
|
+
return errors.map((e) => String(e)).join('; ');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return undefined;
|
|
111
203
|
}
|
|
112
204
|
|
|
113
205
|
function normalizeLogEntry(log: v2.Log): LogEntry {
|
|
@@ -7,6 +7,10 @@ export interface DatadogProjectConfig {
|
|
|
7
7
|
site: string;
|
|
8
8
|
defaultTags?: string[];
|
|
9
9
|
defaultTimeRange: string;
|
|
10
|
+
/** Default RUM application id (@application.id) for RUM event searches. */
|
|
11
|
+
rumApplicationId?: string;
|
|
12
|
+
/** Default service name for RUM event searches (falls back to `service`). */
|
|
13
|
+
rumService?: string;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
interface RawConfig {
|
|
@@ -15,6 +19,8 @@ interface RawConfig {
|
|
|
15
19
|
site?: unknown;
|
|
16
20
|
defaultTags?: unknown;
|
|
17
21
|
defaultTimeRange?: unknown;
|
|
22
|
+
rumApplicationId?: unknown;
|
|
23
|
+
rumService?: unknown;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
const DEFAULT_CONFIG: DatadogProjectConfig = {
|
|
@@ -120,5 +126,19 @@ function validateConfig(raw: RawConfig, configPath: string): DatadogProjectConfi
|
|
|
120
126
|
config.defaultTimeRange = raw.defaultTimeRange;
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
if (raw.rumApplicationId !== undefined) {
|
|
130
|
+
if (typeof raw.rumApplicationId !== 'string') {
|
|
131
|
+
throw new Error(`${configPath}: "rumApplicationId" must be a string`);
|
|
132
|
+
}
|
|
133
|
+
config.rumApplicationId = raw.rumApplicationId;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (raw.rumService !== undefined) {
|
|
137
|
+
if (typeof raw.rumService !== 'string') {
|
|
138
|
+
throw new Error(`${configPath}: "rumService" must be a string`);
|
|
139
|
+
}
|
|
140
|
+
config.rumService = raw.rumService;
|
|
141
|
+
}
|
|
142
|
+
|
|
123
143
|
return config;
|
|
124
144
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Loads a project-root `.env` file into `process.env`.
|
|
6
|
+
*
|
|
7
|
+
* Primary path uses Node's built-in `process.loadEnvFile` (Node >= 20.12).
|
|
8
|
+
* When that API is unavailable (older Node, or non-Node runtimes such as the
|
|
9
|
+
* Bun test runner) it falls back to a minimal inline parser with identical
|
|
10
|
+
* precedence semantics.
|
|
11
|
+
*
|
|
12
|
+
* Precedence follows the dotenv convention: variables already present in the
|
|
13
|
+
* environment (e.g. exported by the shell) take precedence over `.env` values;
|
|
14
|
+
* the file only fills in the gaps.
|
|
15
|
+
*
|
|
16
|
+
* Returns `true` if a `.env` file was found and loaded, `false` if none exists.
|
|
17
|
+
* A missing file or any read/parse error is swallowed so a malformed `.env`
|
|
18
|
+
* never breaks the extension.
|
|
19
|
+
*/
|
|
20
|
+
export function loadDotEnv(cwd: string): boolean {
|
|
21
|
+
const envPath = join(cwd, '.env');
|
|
22
|
+
|
|
23
|
+
if (typeof process.loadEnvFile === 'function') {
|
|
24
|
+
try {
|
|
25
|
+
process.loadEnvFile(envPath);
|
|
26
|
+
return true;
|
|
27
|
+
} catch (err: unknown) {
|
|
28
|
+
if (isENOENT(err)) return false;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return loadDotEnvFallback(envPath);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function loadDotEnvFallback(envPath: string): boolean {
|
|
37
|
+
let raw: string;
|
|
38
|
+
try {
|
|
39
|
+
raw = readFileSync(envPath, 'utf-8');
|
|
40
|
+
} catch (err: unknown) {
|
|
41
|
+
return isENOENT(err) ? false : false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const parsed of parseDotEnv(raw)) {
|
|
45
|
+
// Existing env wins — only fill in unset keys.
|
|
46
|
+
if (process.env[parsed.key] === undefined) {
|
|
47
|
+
process.env[parsed.key] = parsed.value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseDotEnv(raw: string): Array<{ key: string; value: string }> {
|
|
54
|
+
const result: Array<{ key: string; value: string }> = [];
|
|
55
|
+
|
|
56
|
+
for (const line of raw.split('\n')) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
59
|
+
|
|
60
|
+
const withoutExport = trimmed.startsWith('export ') ? trimmed.slice('export '.length) : trimmed;
|
|
61
|
+
|
|
62
|
+
const eq = withoutExport.indexOf('=');
|
|
63
|
+
if (eq === -1) continue;
|
|
64
|
+
|
|
65
|
+
const key = withoutExport.slice(0, eq).trim();
|
|
66
|
+
if (!key) continue;
|
|
67
|
+
|
|
68
|
+
let value = withoutExport.slice(eq + 1).trim();
|
|
69
|
+
// Strip matching surrounding quotes.
|
|
70
|
+
if (
|
|
71
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
72
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
73
|
+
) {
|
|
74
|
+
value = value.slice(1, -1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
result.push({ key, value });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isENOENT(err: unknown): boolean {
|
|
84
|
+
return err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT';
|
|
85
|
+
}
|
|
@@ -3,8 +3,16 @@ import type { LogEntry, LogSearchResult } from './client.js';
|
|
|
3
3
|
const MAX_MESSAGE_LENGTH = 500;
|
|
4
4
|
const MAX_ATTRIBUTES_LENGTH = 300;
|
|
5
5
|
|
|
6
|
+
export interface FormatOptions {
|
|
7
|
+
/** Max message length before truncation. Use Infinity to disable. */
|
|
8
|
+
maxMessageLength?: number;
|
|
9
|
+
/** Max attributes JSON length before truncation. Use Infinity to disable. */
|
|
10
|
+
maxAttributesLength?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
/**
|
|
7
14
|
* Truncates a string to the given max length, appending "…" if truncated.
|
|
15
|
+
* A maxLength of Infinity disables truncation.
|
|
8
16
|
*/
|
|
9
17
|
function truncate(text: string, maxLength: number): string {
|
|
10
18
|
if (text.length <= maxLength) return text;
|
|
@@ -14,7 +22,7 @@ function truncate(text: string, maxLength: number): string {
|
|
|
14
22
|
/**
|
|
15
23
|
* Formats a single log entry as a compact markdown block.
|
|
16
24
|
*/
|
|
17
|
-
function formatLogEntry(log: LogEntry, index: number): string {
|
|
25
|
+
function formatLogEntry(log: LogEntry, index: number, opts: Required<FormatOptions>): string {
|
|
18
26
|
const statusIcon = getStatusIcon(log.status);
|
|
19
27
|
const header = `### ${index + 1}. ${statusIcon} \`${log.status}\` — ${log.timestamp}`;
|
|
20
28
|
|
|
@@ -24,12 +32,12 @@ function formatLogEntry(log: LogEntry, index: number): string {
|
|
|
24
32
|
if (log.host !== 'unknown') lines.push(`**Host:** ${log.host}`);
|
|
25
33
|
|
|
26
34
|
if (log.message) {
|
|
27
|
-
lines.push(`**Message:**\n\`\`\`\n${truncate(log.message,
|
|
35
|
+
lines.push(`**Message:**\n\`\`\`\n${truncate(log.message, opts.maxMessageLength)}\n\`\`\``);
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
const attrKeys = Object.keys(log.attributes);
|
|
31
39
|
if (attrKeys.length > 0) {
|
|
32
|
-
const attrStr = truncate(JSON.stringify(log.attributes, null, 2),
|
|
40
|
+
const attrStr = truncate(JSON.stringify(log.attributes, null, 2), opts.maxAttributesLength);
|
|
33
41
|
lines.push(`**Attributes:**\n\`\`\`json\n${attrStr}\n\`\`\``);
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -51,9 +59,17 @@ function getStatusIcon(status: string): string {
|
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/**
|
|
54
|
-
* Formats the
|
|
62
|
+
* Formats the search result for LLM consumption.
|
|
63
|
+
*
|
|
64
|
+
* By default messages and attributes are truncated for a concise inline view.
|
|
65
|
+
* Pass `{ maxMessageLength: Infinity, maxAttributesLength: Infinity }` to render
|
|
66
|
+
* the complete, untruncated result (e.g. for writing to a temp file).
|
|
55
67
|
*/
|
|
56
|
-
export function formatSearchResult(result: LogSearchResult): string {
|
|
68
|
+
export function formatSearchResult(result: LogSearchResult, opts: FormatOptions = {}): string {
|
|
69
|
+
const resolved: Required<FormatOptions> = {
|
|
70
|
+
maxMessageLength: opts.maxMessageLength ?? MAX_MESSAGE_LENGTH,
|
|
71
|
+
maxAttributesLength: opts.maxAttributesLength ?? MAX_ATTRIBUTES_LENGTH,
|
|
72
|
+
};
|
|
57
73
|
const lines: string[] = [];
|
|
58
74
|
|
|
59
75
|
lines.push(`## Datadog Log Search Results`);
|
|
@@ -77,13 +93,50 @@ export function formatSearchResult(result: LogSearchResult): string {
|
|
|
77
93
|
lines.push('');
|
|
78
94
|
|
|
79
95
|
for (let i = 0; i < result.logs.length; i++) {
|
|
80
|
-
lines.push(formatLogEntry(result.logs[i], i));
|
|
96
|
+
lines.push(formatLogEntry(result.logs[i], i, resolved));
|
|
81
97
|
if (i < result.logs.length - 1) lines.push('');
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
return lines.join('\n');
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Builds a compact, token-light digest of a search result for inline tool
|
|
105
|
+
* output. The full per-entry detail lives in the temp file referenced by
|
|
106
|
+
* `resultsFile`; this just gives the agent enough to decide whether to read it.
|
|
107
|
+
*/
|
|
108
|
+
export function formatResultDigest(result: LogSearchResult, resultsFile?: string): string {
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
lines.push(
|
|
111
|
+
`## Datadog Log Search — ${result.totalCount} log${result.totalCount === 1 ? '' : 's'}`,
|
|
112
|
+
);
|
|
113
|
+
lines.push(`**Query:** \`${result.query}\``);
|
|
114
|
+
lines.push(`**Time range:** ${result.from} → ${result.to}`);
|
|
115
|
+
|
|
116
|
+
if (result.logs.length === 0) {
|
|
117
|
+
lines.push('');
|
|
118
|
+
lines.push('No logs found matching the query.');
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const summary = formatSearchSummary(result);
|
|
123
|
+
const breakdown = Object.entries(summary.statusBreakdown)
|
|
124
|
+
.map(([status, count]) => `${status}: ${count}`)
|
|
125
|
+
.join(', ');
|
|
126
|
+
if (breakdown) lines.push(`**Status:** ${breakdown}`);
|
|
127
|
+
if (summary.services.length > 0) lines.push(`**Services:** ${summary.services.join(', ')}`);
|
|
128
|
+
if (result.cursor) lines.push(`**Pagination:** more results available (cursor present)`);
|
|
129
|
+
|
|
130
|
+
if (resultsFile) {
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push(
|
|
133
|
+
`📄 **Full results** (complete messages & attributes): \`${resultsFile}\`\nUse the \`read\` tool on this file to view every log entry.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
87
140
|
/**
|
|
88
141
|
* Formats a summary of the search result for tool details.
|
|
89
142
|
*/
|
|
@@ -7,8 +7,16 @@ import {
|
|
|
7
7
|
getCredentialStatus,
|
|
8
8
|
loadProjectConfig,
|
|
9
9
|
} from './config.js';
|
|
10
|
-
import { searchLogs } from './client.js';
|
|
11
|
-
import {
|
|
10
|
+
import { searchLogs, describeDatadogError } from './client.js';
|
|
11
|
+
import { searchRumEvents } from './rum-client.js';
|
|
12
|
+
import { loadDotEnv } from './dotenv.js';
|
|
13
|
+
import { formatSearchResult, formatSearchSummary, formatResultDigest } from './format.js';
|
|
14
|
+
import {
|
|
15
|
+
formatRumSearchResult,
|
|
16
|
+
formatRumSearchSummary,
|
|
17
|
+
formatRumResultDigest,
|
|
18
|
+
} from './rum-format.js';
|
|
19
|
+
import { writeResultsFile } from './output.js';
|
|
12
20
|
|
|
13
21
|
const SORT_ENUM = ['newest', 'oldest'] as const;
|
|
14
22
|
|
|
@@ -16,13 +24,26 @@ const TOOL_GUIDELINES = [
|
|
|
16
24
|
'Use `datadog_logs_search` to search production logs in Datadog. It uses Datadog query syntax (e.g. `status:error`, `@http.status_code:500`, `service:my-api`).',
|
|
17
25
|
'`datadog_logs_search` auto-applies project defaults for service and environment from `.pi/datadog.json` — only override when the user explicitly asks for a different service or env.',
|
|
18
26
|
'When `datadog_logs_search` returns many results, summarize the patterns (error types, frequency, affected services) instead of listing every log entry.',
|
|
27
|
+
'The inline `datadog_logs_search` output is a compact digest (counts, status breakdown, services). The complete log entries — full messages and attributes — are written to a temp file whose path is in the response. To inspect actual log content (status codes, paths, stack traces), use the `read` tool on that file instead of re-querying.',
|
|
19
28
|
'Use appropriate time ranges with `datadog_logs_search`: "15m" for recent issues, "1h" for general debugging, "24h" or "7d" for trend analysis.',
|
|
29
|
+
"Datadog is rate-limit sensitive: prefer a single narrow, well-scoped `datadog_logs_search` over many broad calls in quick succession. The tool already auto-retries on 429 (honouring Datadog's reset window) — if it still reports a rate limit, wait before retrying rather than firing again immediately.",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const RUM_TOOL_GUIDELINES = [
|
|
33
|
+
'Use `datadog_rum_search` to search Datadog RUM (Real User Monitoring) events — user sessions, views, actions, and front-end errors. It uses Datadog RUM query syntax (e.g. `@type:session`, `@type:error`, `@view.url_path:/checkout`, `@session.type:user`).',
|
|
34
|
+
'`datadog_rum_search` defaults to session events (`@type:session`) when your query does not specify `@type`. To inspect other event kinds, include `@type:view`, `@type:action`, or `@type:error` in the query.',
|
|
35
|
+
'`datadog_rum_search` auto-applies project defaults for RUM application, service, and environment from `.pi/datadog.json` — only override when the user explicitly asks for a different application, service, or env.',
|
|
36
|
+
'The inline `datadog_rum_search` output is a compact digest (counts, event-type breakdown, services). The complete events — full attributes, session ids, view URLs — are written to a temp file whose path is in the response. To inspect actual event content, use the `read` tool on that file instead of re-querying.',
|
|
37
|
+
'Use appropriate time ranges with `datadog_rum_search`: "15m" for recent issues, "1h" for general debugging, "24h" or "7d" for trend analysis.',
|
|
20
38
|
];
|
|
21
39
|
|
|
22
40
|
export default function datadogExtension(pi: ExtensionAPI) {
|
|
23
41
|
let projectConfig: DatadogProjectConfig | null = null;
|
|
24
42
|
|
|
25
43
|
pi.on('session_start', async (_event, ctx) => {
|
|
44
|
+
// Load project-root .env so DD_API_KEY / DD_APP_KEY defined there are
|
|
45
|
+
// visible. Shell-exported vars still take precedence.
|
|
46
|
+
loadDotEnv(ctx.cwd);
|
|
26
47
|
try {
|
|
27
48
|
projectConfig = await loadProjectConfig(ctx.cwd);
|
|
28
49
|
} catch (err) {
|
|
@@ -93,6 +114,9 @@ export default function datadogExtension(pi: ExtensionAPI) {
|
|
|
93
114
|
_onUpdate?: unknown,
|
|
94
115
|
ctx?: { cwd: string },
|
|
95
116
|
) {
|
|
117
|
+
// Ensure .env is loaded in case the tool runs before session_start.
|
|
118
|
+
if (ctx?.cwd) loadDotEnv(ctx.cwd);
|
|
119
|
+
|
|
96
120
|
const credentials = getCredentials();
|
|
97
121
|
if (!credentials) {
|
|
98
122
|
const status = getCredentialStatus();
|
|
@@ -127,23 +151,190 @@ export default function datadogExtension(pi: ExtensionAPI) {
|
|
|
127
151
|
|
|
128
152
|
try {
|
|
129
153
|
const result = await searchLogs(params, config, credentials);
|
|
130
|
-
const formatted = formatSearchResult(result);
|
|
131
154
|
const summary = formatSearchSummary(result);
|
|
132
155
|
|
|
156
|
+
// Write the full, untruncated results to a temp file the agent can read,
|
|
157
|
+
// and return only a compact digest inline to save tokens.
|
|
158
|
+
let resultsFile: string | undefined;
|
|
159
|
+
if (result.logs.length > 0) {
|
|
160
|
+
try {
|
|
161
|
+
const fullContent = formatSearchResult(result, {
|
|
162
|
+
maxMessageLength: Infinity,
|
|
163
|
+
maxAttributesLength: Infinity,
|
|
164
|
+
});
|
|
165
|
+
resultsFile = await writeResultsFile(fullContent);
|
|
166
|
+
} catch {
|
|
167
|
+
// Non-fatal: digest still carries the summary.
|
|
168
|
+
resultsFile = undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: 'text' as const, text: formatResultDigest(result, resultsFile) }],
|
|
174
|
+
details: { ...summary, resultsFile } as Record<string, unknown>,
|
|
175
|
+
};
|
|
176
|
+
} catch (err) {
|
|
177
|
+
const info = describeDatadogError(err);
|
|
178
|
+
return {
|
|
179
|
+
content: [
|
|
180
|
+
{
|
|
181
|
+
type: 'text' as const,
|
|
182
|
+
text: `❌ ${info.message}`,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
details: {
|
|
186
|
+
error: info.isRateLimit ? 'rate_limited' : 'api_error',
|
|
187
|
+
code: info.code,
|
|
188
|
+
message: info.message,
|
|
189
|
+
} as Record<string, unknown>,
|
|
190
|
+
isError: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
pi.registerTool({
|
|
197
|
+
name: 'datadog_rum_search',
|
|
198
|
+
label: 'Datadog RUM Search',
|
|
199
|
+
description:
|
|
200
|
+
'Search Datadog RUM events (sessions, views, actions, errors) with RUM query syntax. Defaults to session events and uses project defaults from .pi/datadog.json for RUM application, service, environment, and time range.',
|
|
201
|
+
promptSnippet:
|
|
202
|
+
'Search Datadog RUM events (sessions/views/actions/errors) with project-aware defaults',
|
|
203
|
+
promptGuidelines: RUM_TOOL_GUIDELINES,
|
|
204
|
+
parameters: Type.Object({
|
|
205
|
+
query: Type.String({
|
|
206
|
+
description:
|
|
207
|
+
'Datadog RUM query syntax (e.g. "@type:session", "@type:error", "@view.url_path:/checkout"). Leave empty to list recent sessions.',
|
|
208
|
+
}),
|
|
209
|
+
from: Type.Optional(
|
|
210
|
+
Type.String({
|
|
211
|
+
description:
|
|
212
|
+
'Start time — relative (15m, 1h, 7d) or ISO 8601. Defaults to project config or 1h.',
|
|
213
|
+
}),
|
|
214
|
+
),
|
|
215
|
+
to: Type.Optional(
|
|
216
|
+
Type.String({
|
|
217
|
+
description: 'End time — relative, ISO 8601, or "now". Defaults to "now".',
|
|
218
|
+
}),
|
|
219
|
+
),
|
|
220
|
+
limit: Type.Optional(
|
|
221
|
+
Type.Number({
|
|
222
|
+
description: 'Max events to return (1-100). Default 25.',
|
|
223
|
+
minimum: 1,
|
|
224
|
+
maximum: 100,
|
|
225
|
+
}),
|
|
226
|
+
),
|
|
227
|
+
sort: Type.Optional(
|
|
228
|
+
StringEnum(SORT_ENUM, {
|
|
229
|
+
description: 'Sort order by timestamp. Default "newest".',
|
|
230
|
+
}),
|
|
231
|
+
),
|
|
232
|
+
service: Type.Optional(
|
|
233
|
+
Type.String({
|
|
234
|
+
description:
|
|
235
|
+
'Service name — overrides project rumService/service default from .pi/datadog.json.',
|
|
236
|
+
}),
|
|
237
|
+
),
|
|
238
|
+
env: Type.Optional(
|
|
239
|
+
Type.String({
|
|
240
|
+
description: 'Environment — overrides project default from .pi/datadog.json.',
|
|
241
|
+
}),
|
|
242
|
+
),
|
|
243
|
+
applicationId: Type.Optional(
|
|
244
|
+
Type.String({
|
|
245
|
+
description:
|
|
246
|
+
'RUM application id (@application.id) — overrides project rumApplicationId default.',
|
|
247
|
+
}),
|
|
248
|
+
),
|
|
249
|
+
}),
|
|
250
|
+
|
|
251
|
+
async execute(
|
|
252
|
+
_toolCallId: string,
|
|
253
|
+
params: {
|
|
254
|
+
query: string;
|
|
255
|
+
from?: string;
|
|
256
|
+
to?: string;
|
|
257
|
+
limit?: number;
|
|
258
|
+
sort?: (typeof SORT_ENUM)[number];
|
|
259
|
+
service?: string;
|
|
260
|
+
env?: string;
|
|
261
|
+
applicationId?: string;
|
|
262
|
+
},
|
|
263
|
+
_signal?: AbortSignal,
|
|
264
|
+
_onUpdate?: unknown,
|
|
265
|
+
ctx?: { cwd: string },
|
|
266
|
+
) {
|
|
267
|
+
// Ensure .env is loaded in case the tool runs before session_start.
|
|
268
|
+
if (ctx?.cwd) loadDotEnv(ctx.cwd);
|
|
269
|
+
|
|
270
|
+
const credentials = getCredentials();
|
|
271
|
+
if (!credentials) {
|
|
272
|
+
const status = getCredentialStatus();
|
|
273
|
+
const missing = [
|
|
274
|
+
!status.hasApiKey && 'DD_API_KEY',
|
|
275
|
+
!status.hasAppKey && 'DD_APP_KEY',
|
|
276
|
+
].filter(Boolean);
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
content: [
|
|
280
|
+
{
|
|
281
|
+
type: 'text' as const,
|
|
282
|
+
text: `❌ Missing Datadog credentials: ${missing.join(', ')}.\n\nSet these environment variables to enable Datadog RUM search.`,
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
details: { error: 'missing_credentials', missing } as Record<string, unknown>,
|
|
286
|
+
isError: true,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Reload config if not loaded yet (e.g. tool called before session_start)
|
|
291
|
+
if (!projectConfig && ctx?.cwd) {
|
|
292
|
+
try {
|
|
293
|
+
projectConfig = await loadProjectConfig(ctx.cwd);
|
|
294
|
+
} catch {
|
|
295
|
+
projectConfig = { site: 'datadoghq.com', defaultTimeRange: '1h' };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const config = projectConfig ?? { site: 'datadoghq.com', defaultTimeRange: '1h' };
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const result = await searchRumEvents(params, config, credentials);
|
|
303
|
+
const summary = formatRumSearchSummary(result);
|
|
304
|
+
|
|
305
|
+
// Write the full, untruncated results to a temp file the agent can read,
|
|
306
|
+
// and return only a compact digest inline to save tokens.
|
|
307
|
+
let resultsFile: string | undefined;
|
|
308
|
+
if (result.events.length > 0) {
|
|
309
|
+
try {
|
|
310
|
+
const fullContent = formatRumSearchResult(result, {
|
|
311
|
+
maxAttributesLength: Infinity,
|
|
312
|
+
});
|
|
313
|
+
resultsFile = await writeResultsFile(fullContent, 'rum');
|
|
314
|
+
} catch {
|
|
315
|
+
// Non-fatal: digest still carries the summary.
|
|
316
|
+
resultsFile = undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
133
320
|
return {
|
|
134
|
-
content: [{ type: 'text' as const, text:
|
|
135
|
-
details: summary as Record<string, unknown>,
|
|
321
|
+
content: [{ type: 'text' as const, text: formatRumResultDigest(result, resultsFile) }],
|
|
322
|
+
details: { ...summary, resultsFile } as Record<string, unknown>,
|
|
136
323
|
};
|
|
137
324
|
} catch (err) {
|
|
138
|
-
const
|
|
325
|
+
const info = describeDatadogError(err);
|
|
139
326
|
return {
|
|
140
327
|
content: [
|
|
141
328
|
{
|
|
142
329
|
type: 'text' as const,
|
|
143
|
-
text: `❌
|
|
330
|
+
text: `❌ ${info.message}`,
|
|
144
331
|
},
|
|
145
332
|
],
|
|
146
|
-
details: {
|
|
333
|
+
details: {
|
|
334
|
+
error: info.isRateLimit ? 'rate_limited' : 'api_error',
|
|
335
|
+
code: info.code,
|
|
336
|
+
message: info.message,
|
|
337
|
+
} as Record<string, unknown>,
|
|
147
338
|
isError: true,
|
|
148
339
|
};
|
|
149
340
|
}
|
|
@@ -160,6 +351,7 @@ export default function datadogExtension(pi: ExtensionAPI) {
|
|
|
160
351
|
ui: { notify(message: string, level: 'info' | 'warning' | 'error'): void };
|
|
161
352
|
},
|
|
162
353
|
) => {
|
|
354
|
+
loadDotEnv(ctx.cwd);
|
|
163
355
|
const credStatus = getCredentialStatus();
|
|
164
356
|
const config = projectConfig ?? (await loadProjectConfig(ctx.cwd).catch(() => null));
|
|
165
357
|
|
|
@@ -180,6 +372,8 @@ export default function datadogExtension(pi: ExtensionAPI) {
|
|
|
180
372
|
if (config.defaultTags?.length) {
|
|
181
373
|
lines.push(` Default tags: ${config.defaultTags.join(', ')}`);
|
|
182
374
|
}
|
|
375
|
+
lines.push(` RUM application: ${config.rumApplicationId ?? '(not set)'}`);
|
|
376
|
+
lines.push(` RUM service: ${config.rumService ?? '(not set)'}`);
|
|
183
377
|
} else {
|
|
184
378
|
lines.push('No .pi/datadog.json found — using defaults.');
|
|
185
379
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Writes the full (untruncated) search results to a temp file the agent can
|
|
7
|
+
* read freely with the `read` tool, sidestepping the inline truncation that
|
|
8
|
+
* hides long log messages and attributes.
|
|
9
|
+
*
|
|
10
|
+
* The `prefix` controls the filename stem (e.g. "logs" → `logs-<ts>.md`,
|
|
11
|
+
* "rum" → `rum-<ts>.md`) so logs and RUM results stay distinguishable on disk.
|
|
12
|
+
*
|
|
13
|
+
* Returns the absolute path to the written file.
|
|
14
|
+
*/
|
|
15
|
+
export async function writeResultsFile(content: string, prefix = 'logs'): Promise<string> {
|
|
16
|
+
const dir = await mkdtemp(join(tmpdir(), 'pi-datadog-'));
|
|
17
|
+
const path = join(dir, `${prefix}-${Date.now()}.md`);
|
|
18
|
+
await writeFile(path, content, 'utf-8');
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { v2 } from '@datadog/datadog-api-client';
|
|
2
|
+
import type { DatadogCredentials, DatadogProjectConfig } from './config.js';
|
|
3
|
+
import { createConfiguration, resolveTime } from './client.js';
|
|
4
|
+
|
|
5
|
+
export interface RumSearchParams {
|
|
6
|
+
query: string;
|
|
7
|
+
from?: string;
|
|
8
|
+
to?: string;
|
|
9
|
+
limit?: number;
|
|
10
|
+
sort?: 'newest' | 'oldest';
|
|
11
|
+
service?: string;
|
|
12
|
+
env?: string;
|
|
13
|
+
applicationId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RumEvent {
|
|
17
|
+
id: string;
|
|
18
|
+
/** RUM event type (session, view, action, error, …) from the event attributes. */
|
|
19
|
+
eventType: string;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
service: string;
|
|
22
|
+
tags: string[];
|
|
23
|
+
attributes: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RumSearchResult {
|
|
27
|
+
events: RumEvent[];
|
|
28
|
+
totalCount: number;
|
|
29
|
+
query: string;
|
|
30
|
+
from: string;
|
|
31
|
+
to: string;
|
|
32
|
+
cursor?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Builds the full RUM query by merging the user query with config defaults.
|
|
37
|
+
*
|
|
38
|
+
* Defaults to session events (`@type:session`) unless the query already scopes
|
|
39
|
+
* `@type`, so views/actions/errors stay reachable. Application, service, env,
|
|
40
|
+
* and tag defaults are merged the same way as the logs client, skipping any
|
|
41
|
+
* dimension the user already constrained.
|
|
42
|
+
*/
|
|
43
|
+
export function buildRumQuery(params: RumSearchParams, config: DatadogProjectConfig): string {
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
if (params.query.trim().length > 0) parts.push(params.query);
|
|
46
|
+
|
|
47
|
+
if (!params.query.includes('@type:')) {
|
|
48
|
+
parts.push('@type:session');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const applicationId = params.applicationId ?? config.rumApplicationId;
|
|
52
|
+
if (applicationId && !params.query.includes('@application.id:')) {
|
|
53
|
+
parts.push(`@application.id:${applicationId}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const service = params.service ?? config.rumService ?? config.service;
|
|
57
|
+
if (service && !params.query.includes('service:')) {
|
|
58
|
+
parts.push(`service:${service}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const env = params.env ?? config.env;
|
|
62
|
+
if (env && !params.query.includes('env:')) {
|
|
63
|
+
parts.push(`env:${env}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (config.defaultTags) {
|
|
67
|
+
for (const tag of config.defaultTags) {
|
|
68
|
+
const tagKey = tag.split(':')[0];
|
|
69
|
+
if (!params.query.includes(`${tagKey}:`)) {
|
|
70
|
+
parts.push(tag);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return parts.join(' ');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalizes a raw RUM event from the SDK into a flat, agent-friendly shape.
|
|
80
|
+
*/
|
|
81
|
+
export function normalizeRumEvent(event: v2.RUMEvent): RumEvent {
|
|
82
|
+
const attrs = event.attributes;
|
|
83
|
+
const nested = (attrs?.attributes as Record<string, unknown> | undefined) ?? {};
|
|
84
|
+
const eventType = typeof nested.type === 'string' ? nested.type : 'unknown';
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id: event.id ?? 'unknown',
|
|
88
|
+
eventType,
|
|
89
|
+
timestamp: attrs?.timestamp?.toISOString() ?? 'unknown',
|
|
90
|
+
service: attrs?.service ?? 'unknown',
|
|
91
|
+
tags: attrs?.tags ?? [],
|
|
92
|
+
attributes: nested,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Searches Datadog RUM events using the v2 API.
|
|
98
|
+
*/
|
|
99
|
+
export async function searchRumEvents(
|
|
100
|
+
params: RumSearchParams,
|
|
101
|
+
config: DatadogProjectConfig,
|
|
102
|
+
credentials: DatadogCredentials,
|
|
103
|
+
): Promise<RumSearchResult> {
|
|
104
|
+
const fullQuery = buildRumQuery(params, config);
|
|
105
|
+
const fromTime = resolveTime(params.from ?? config.defaultTimeRange);
|
|
106
|
+
const toTime = resolveTime(params.to ?? 'now');
|
|
107
|
+
const limit = Math.min(params.limit ?? 25, 100);
|
|
108
|
+
const sortOrder = params.sort === 'oldest' ? 'timestamp' : '-timestamp';
|
|
109
|
+
|
|
110
|
+
const api = new v2.RUMApi(createConfiguration(credentials, config.site));
|
|
111
|
+
|
|
112
|
+
const response = await api.listRUMEvents({
|
|
113
|
+
filterQuery: fullQuery,
|
|
114
|
+
filterFrom: fromTime,
|
|
115
|
+
filterTo: toTime,
|
|
116
|
+
pageLimit: limit,
|
|
117
|
+
sort: sortOrder as v2.RUMSort,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const events = (response.data ?? []).map(normalizeRumEvent);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
events,
|
|
124
|
+
totalCount: events.length,
|
|
125
|
+
query: fullQuery,
|
|
126
|
+
from: fromTime.toISOString(),
|
|
127
|
+
to: toTime.toISOString(),
|
|
128
|
+
cursor: response.meta?.page?.after,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { RumEvent, RumSearchResult } from './rum-client.js';
|
|
2
|
+
|
|
3
|
+
const MAX_ATTRIBUTES_LENGTH = 300;
|
|
4
|
+
|
|
5
|
+
export interface RumFormatOptions {
|
|
6
|
+
/** Max attributes JSON length before truncation. Use Infinity to disable. */
|
|
7
|
+
maxAttributesLength?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Truncates a string to the given max length, appending "…" if truncated.
|
|
12
|
+
* A maxLength of Infinity disables truncation.
|
|
13
|
+
*/
|
|
14
|
+
function truncate(text: string, maxLength: number): string {
|
|
15
|
+
if (text.length <= maxLength) return text;
|
|
16
|
+
return `${text.slice(0, maxLength - 1)}…`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getTypeIcon(eventType: string): string {
|
|
20
|
+
switch (eventType.toLowerCase()) {
|
|
21
|
+
case 'error':
|
|
22
|
+
return '🔴';
|
|
23
|
+
case 'session':
|
|
24
|
+
return '👤';
|
|
25
|
+
case 'view':
|
|
26
|
+
return '📄';
|
|
27
|
+
case 'action':
|
|
28
|
+
return '🖱️';
|
|
29
|
+
case 'long_task':
|
|
30
|
+
case 'resource':
|
|
31
|
+
return '📦';
|
|
32
|
+
default:
|
|
33
|
+
return '⚪';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reads a dotted-ish nested value (e.g. session.id) from the attribute bag. */
|
|
38
|
+
function readNested(
|
|
39
|
+
attributes: Record<string, unknown>,
|
|
40
|
+
group: string,
|
|
41
|
+
key: string,
|
|
42
|
+
): string | undefined {
|
|
43
|
+
const obj = attributes[group];
|
|
44
|
+
if (typeof obj === 'object' && obj !== null && key in obj) {
|
|
45
|
+
const value = (obj as Record<string, unknown>)[key];
|
|
46
|
+
if (value !== undefined && value !== null) return String(value);
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function formatRumEvent(event: RumEvent, index: number, opts: Required<RumFormatOptions>): string {
|
|
52
|
+
const icon = getTypeIcon(event.eventType);
|
|
53
|
+
const header = `### ${index + 1}. ${icon} \`${event.eventType}\` — ${event.timestamp}`;
|
|
54
|
+
|
|
55
|
+
const lines: string[] = [header];
|
|
56
|
+
|
|
57
|
+
if (event.service !== 'unknown') lines.push(`**Service:** ${event.service}`);
|
|
58
|
+
|
|
59
|
+
const sessionId = readNested(event.attributes, 'session', 'id');
|
|
60
|
+
if (sessionId) lines.push(`**Session:** ${sessionId}`);
|
|
61
|
+
|
|
62
|
+
const viewUrl =
|
|
63
|
+
readNested(event.attributes, 'view', 'url') ?? readNested(event.attributes, 'view', 'url_path');
|
|
64
|
+
if (viewUrl) lines.push(`**View:** ${viewUrl}`);
|
|
65
|
+
|
|
66
|
+
const attrKeys = Object.keys(event.attributes);
|
|
67
|
+
if (attrKeys.length > 0) {
|
|
68
|
+
const attrStr = truncate(JSON.stringify(event.attributes, null, 2), opts.maxAttributesLength);
|
|
69
|
+
lines.push(`**Attributes:**\n\`\`\`json\n${attrStr}\n\`\`\``);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (event.tags.length > 0) {
|
|
73
|
+
lines.push(`**Tags:** ${event.tags.map((t) => `\`${t}\``).join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Formats the RUM search result for LLM consumption.
|
|
81
|
+
*
|
|
82
|
+
* By default attributes are truncated for a concise inline view. Pass
|
|
83
|
+
* `{ maxAttributesLength: Infinity }` to render the complete, untruncated
|
|
84
|
+
* result (e.g. for writing to a temp file).
|
|
85
|
+
*/
|
|
86
|
+
export function formatRumSearchResult(
|
|
87
|
+
result: RumSearchResult,
|
|
88
|
+
opts: RumFormatOptions = {},
|
|
89
|
+
): string {
|
|
90
|
+
const resolved: Required<RumFormatOptions> = {
|
|
91
|
+
maxAttributesLength: opts.maxAttributesLength ?? MAX_ATTRIBUTES_LENGTH,
|
|
92
|
+
};
|
|
93
|
+
const lines: string[] = [];
|
|
94
|
+
|
|
95
|
+
lines.push(`## Datadog RUM Event Search Results`);
|
|
96
|
+
lines.push('');
|
|
97
|
+
lines.push(`**Query:** \`${result.query}\``);
|
|
98
|
+
lines.push(`**Time range:** ${result.from} → ${result.to}`);
|
|
99
|
+
lines.push(`**Results:** ${result.totalCount} events returned`);
|
|
100
|
+
|
|
101
|
+
if (result.cursor) {
|
|
102
|
+
lines.push(`**Pagination:** More results available (cursor present)`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
lines.push('');
|
|
106
|
+
|
|
107
|
+
if (result.events.length === 0) {
|
|
108
|
+
lines.push('No RUM events found matching the query.');
|
|
109
|
+
return lines.join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lines.push('---');
|
|
113
|
+
lines.push('');
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < result.events.length; i++) {
|
|
116
|
+
lines.push(formatRumEvent(result.events[i], i, resolved));
|
|
117
|
+
if (i < result.events.length - 1) lines.push('');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds a compact, token-light digest of a RUM search result for inline tool
|
|
125
|
+
* output. The full per-entry detail lives in the temp file referenced by
|
|
126
|
+
* `resultsFile`; this just gives the agent enough to decide whether to read it.
|
|
127
|
+
*/
|
|
128
|
+
export function formatRumResultDigest(result: RumSearchResult, resultsFile?: string): string {
|
|
129
|
+
const lines: string[] = [];
|
|
130
|
+
lines.push(
|
|
131
|
+
`## Datadog RUM Search — ${result.totalCount} RUM event${result.totalCount === 1 ? '' : 's'}`,
|
|
132
|
+
);
|
|
133
|
+
lines.push(`**Query:** \`${result.query}\``);
|
|
134
|
+
lines.push(`**Time range:** ${result.from} → ${result.to}`);
|
|
135
|
+
|
|
136
|
+
if (result.events.length === 0) {
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push('No RUM events found matching the query.');
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const summary = formatRumSearchSummary(result);
|
|
143
|
+
const breakdown = Object.entries(summary.typeBreakdown)
|
|
144
|
+
.map(([type, count]) => `${type}: ${count}`)
|
|
145
|
+
.join(', ');
|
|
146
|
+
if (breakdown) lines.push(`**Types:** ${breakdown}`);
|
|
147
|
+
if (summary.services.length > 0) lines.push(`**Services:** ${summary.services.join(', ')}`);
|
|
148
|
+
if (result.cursor) lines.push(`**Pagination:** more results available (cursor present)`);
|
|
149
|
+
|
|
150
|
+
if (resultsFile) {
|
|
151
|
+
lines.push('');
|
|
152
|
+
lines.push(
|
|
153
|
+
`📄 **Full results** (complete event attributes): \`${resultsFile}\`\nUse the \`read\` tool on this file to view every RUM event.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Formats a summary of the RUM search result for tool details.
|
|
162
|
+
*/
|
|
163
|
+
export function formatRumSearchSummary(result: RumSearchResult): {
|
|
164
|
+
totalCount: number;
|
|
165
|
+
query: string;
|
|
166
|
+
timeRange: { from: string; to: string };
|
|
167
|
+
typeBreakdown: Record<string, number>;
|
|
168
|
+
services: string[];
|
|
169
|
+
hasCursor: boolean;
|
|
170
|
+
} {
|
|
171
|
+
const typeBreakdown: Record<string, number> = {};
|
|
172
|
+
const services = new Set<string>();
|
|
173
|
+
|
|
174
|
+
for (const event of result.events) {
|
|
175
|
+
const type = event.eventType.toLowerCase();
|
|
176
|
+
typeBreakdown[type] = (typeBreakdown[type] ?? 0) + 1;
|
|
177
|
+
if (event.service !== 'unknown') services.add(event.service);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
totalCount: result.totalCount,
|
|
182
|
+
query: result.query,
|
|
183
|
+
timeRange: { from: result.from, to: result.to },
|
|
184
|
+
typeBreakdown,
|
|
185
|
+
services: [...services],
|
|
186
|
+
hasCursor: Boolean(result.cursor),
|
|
187
|
+
};
|
|
188
|
+
}
|