@dreki-gg/pi-datadog 0.2.0 → 0.2.4

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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # @dreki-gg/pi-datadog
2
2
 
3
+ ## 0.2.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Return a compact digest inline and write full log entries to a temp file.
8
+
9
+ `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.
10
+
11
+ ## 0.2.3
12
+
13
+ ### Patch Changes
14
+
15
+ - Write full untruncated log search results to a temp file the agent can read.
16
+
17
+ 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.
18
+
19
+ ## 0.2.2
20
+
21
+ ### Patch Changes
22
+
23
+ - Handle Datadog rate limits gracefully instead of failing on 429s.
24
+
25
+ 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.
26
+
27
+ ## 0.2.1
28
+
29
+ ### Patch Changes
30
+
31
+ - Load project-root `.env` files so `DD_API_KEY` / `DD_APP_KEY` defined there are picked up.
32
+
33
+ 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.
34
+
3
35
  ## 0.2.0
4
36
 
5
37
  ### Minor Changes
@@ -98,18 +98,102 @@ export function buildQuery(params: LogSearchParams, config: DatadogProjectConfig
98
98
  return parts.join(' ');
99
99
  }
100
100
 
101
+ /** Max automatic retry attempts on 429 / 5xx responses. */
102
+ const MAX_RETRIES = 4;
103
+
101
104
  function createApiInstance(credentials: DatadogCredentials, site: string): v2.LogsApi {
102
105
  const configuration = client.createConfiguration({
103
106
  authMethods: {
104
107
  apiKeyAuth: credentials.apiKey,
105
108
  appKeyAuth: credentials.appKey,
106
109
  },
110
+ // Transparently retry on 429 / 5xx. The SDK honours the `x-ratelimit-reset`
111
+ // header, so it waits exactly the window Datadog asks for before retrying
112
+ // (falling back to exponential backoff otherwise). This is what keeps the
113
+ // tool from "crying" about rate limits on bursty usage.
114
+ enableRetry: true,
115
+ maxRetries: MAX_RETRIES,
107
116
  });
108
117
  configuration.setServerVariables({ site });
109
118
 
110
119
  return new v2.LogsApi(configuration);
111
120
  }
112
121
 
122
+ export interface DatadogErrorInfo {
123
+ /** Human-readable, agent-facing message. */
124
+ message: string;
125
+ /** HTTP status code, when the error came from the Datadog API. */
126
+ code?: number;
127
+ /** True when the error is a rate-limit (429) after retries were exhausted. */
128
+ isRateLimit: boolean;
129
+ }
130
+
131
+ /**
132
+ * Classifies an error thrown by the Datadog SDK into a clear, actionable message.
133
+ *
134
+ * The SDK throws an `ApiException`-shaped object (`{ code, body }`) on non-2xx
135
+ * responses. A 429 here means retries were already exhausted, so the message
136
+ * tells the agent to back off and keep queries narrow/batched rather than
137
+ * firing more requests.
138
+ */
139
+ export function describeDatadogError(err: unknown): DatadogErrorInfo {
140
+ const code = extractStatusCode(err);
141
+ const detail = extractApiMessage(err);
142
+
143
+ if (code === 429) {
144
+ return {
145
+ code,
146
+ isRateLimit: true,
147
+ message:
148
+ `Rate limited by Datadog (429) after ${MAX_RETRIES} retries${detail ? `: ${detail}` : ''}. ` +
149
+ 'Wait before retrying, and keep queries narrow/batched instead of firing many in quick succession.',
150
+ };
151
+ }
152
+
153
+ if (code === 401 || code === 403) {
154
+ return {
155
+ code,
156
+ isRateLimit: false,
157
+ message: `Datadog auth error (${code})${detail ? `: ${detail}` : ''}. Check DD_API_KEY / DD_APP_KEY.`,
158
+ };
159
+ }
160
+
161
+ if (code !== undefined) {
162
+ return {
163
+ code,
164
+ isRateLimit: false,
165
+ message: `Datadog API error (${code})${detail ? `: ${detail}` : ''}.`,
166
+ };
167
+ }
168
+
169
+ return {
170
+ isRateLimit: false,
171
+ message: `Datadog API error: ${err instanceof Error ? err.message : String(err)}`,
172
+ };
173
+ }
174
+
175
+ function extractStatusCode(err: unknown): number | undefined {
176
+ if (typeof err === 'object' && err !== null && 'code' in err) {
177
+ const code = (err as { code: unknown }).code;
178
+ if (typeof code === 'number') return code;
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ function extractApiMessage(err: unknown): string | undefined {
184
+ if (typeof err !== 'object' || err === null || !('body' in err)) return undefined;
185
+ const body = (err as { body: unknown }).body;
186
+
187
+ if (typeof body === 'string') return body || undefined;
188
+ if (typeof body === 'object' && body !== null && 'errors' in body) {
189
+ const errors = (body as { errors: unknown }).errors;
190
+ if (Array.isArray(errors) && errors.length > 0) {
191
+ return errors.map((e) => String(e)).join('; ');
192
+ }
193
+ }
194
+ return undefined;
195
+ }
196
+
113
197
  function normalizeLogEntry(log: v2.Log): LogEntry {
114
198
  const attrs = log.attributes;
115
199
  return {
@@ -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, MAX_MESSAGE_LENGTH)}\n\`\`\``);
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), MAX_ATTRIBUTES_LENGTH);
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 full search result for LLM consumption.
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,10 @@ import {
7
7
  getCredentialStatus,
8
8
  loadProjectConfig,
9
9
  } from './config.js';
10
- import { searchLogs } from './client.js';
11
- import { formatSearchResult, formatSearchSummary } from './format.js';
10
+ import { searchLogs, describeDatadogError } from './client.js';
11
+ import { loadDotEnv } from './dotenv.js';
12
+ import { formatSearchResult, formatSearchSummary, formatResultDigest } from './format.js';
13
+ import { writeResultsFile } from './output.js';
12
14
 
13
15
  const SORT_ENUM = ['newest', 'oldest'] as const;
14
16
 
@@ -16,13 +18,18 @@ const TOOL_GUIDELINES = [
16
18
  '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
19
  '`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
20
  'When `datadog_logs_search` returns many results, summarize the patterns (error types, frequency, affected services) instead of listing every log entry.',
21
+ '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
22
  'Use appropriate time ranges with `datadog_logs_search`: "15m" for recent issues, "1h" for general debugging, "24h" or "7d" for trend analysis.',
23
+ "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.",
20
24
  ];
21
25
 
22
26
  export default function datadogExtension(pi: ExtensionAPI) {
23
27
  let projectConfig: DatadogProjectConfig | null = null;
24
28
 
25
29
  pi.on('session_start', async (_event, ctx) => {
30
+ // Load project-root .env so DD_API_KEY / DD_APP_KEY defined there are
31
+ // visible. Shell-exported vars still take precedence.
32
+ loadDotEnv(ctx.cwd);
26
33
  try {
27
34
  projectConfig = await loadProjectConfig(ctx.cwd);
28
35
  } catch (err) {
@@ -93,6 +100,9 @@ export default function datadogExtension(pi: ExtensionAPI) {
93
100
  _onUpdate?: unknown,
94
101
  ctx?: { cwd: string },
95
102
  ) {
103
+ // Ensure .env is loaded in case the tool runs before session_start.
104
+ if (ctx?.cwd) loadDotEnv(ctx.cwd);
105
+
96
106
  const credentials = getCredentials();
97
107
  if (!credentials) {
98
108
  const status = getCredentialStatus();
@@ -127,23 +137,42 @@ export default function datadogExtension(pi: ExtensionAPI) {
127
137
 
128
138
  try {
129
139
  const result = await searchLogs(params, config, credentials);
130
- const formatted = formatSearchResult(result);
131
140
  const summary = formatSearchSummary(result);
132
141
 
142
+ // Write the full, untruncated results to a temp file the agent can read,
143
+ // and return only a compact digest inline to save tokens.
144
+ let resultsFile: string | undefined;
145
+ if (result.logs.length > 0) {
146
+ try {
147
+ const fullContent = formatSearchResult(result, {
148
+ maxMessageLength: Infinity,
149
+ maxAttributesLength: Infinity,
150
+ });
151
+ resultsFile = await writeResultsFile(fullContent);
152
+ } catch {
153
+ // Non-fatal: digest still carries the summary.
154
+ resultsFile = undefined;
155
+ }
156
+ }
157
+
133
158
  return {
134
- content: [{ type: 'text' as const, text: formatted }],
135
- details: summary as Record<string, unknown>,
159
+ content: [{ type: 'text' as const, text: formatResultDigest(result, resultsFile) }],
160
+ details: { ...summary, resultsFile } as Record<string, unknown>,
136
161
  };
137
162
  } catch (err) {
138
- const message = err instanceof Error ? err.message : String(err);
163
+ const info = describeDatadogError(err);
139
164
  return {
140
165
  content: [
141
166
  {
142
167
  type: 'text' as const,
143
- text: `❌ Datadog API error: ${message}`,
168
+ text: `❌ ${info.message}`,
144
169
  },
145
170
  ],
146
- details: { error: 'api_error', message } as Record<string, unknown>,
171
+ details: {
172
+ error: info.isRateLimit ? 'rate_limited' : 'api_error',
173
+ code: info.code,
174
+ message: info.message,
175
+ } as Record<string, unknown>,
147
176
  isError: true,
148
177
  };
149
178
  }
@@ -160,6 +189,7 @@ export default function datadogExtension(pi: ExtensionAPI) {
160
189
  ui: { notify(message: string, level: 'info' | 'warning' | 'error'): void };
161
190
  },
162
191
  ) => {
192
+ loadDotEnv(ctx.cwd);
163
193
  const credStatus = getCredentialStatus();
164
194
  const config = projectConfig ?? (await loadProjectConfig(ctx.cwd).catch(() => null));
165
195
 
@@ -0,0 +1,17 @@
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
+ * Returns the absolute path to the written file.
11
+ */
12
+ export async function writeResultsFile(content: string): Promise<string> {
13
+ const dir = await mkdtemp(join(tmpdir(), 'pi-datadog-'));
14
+ const path = join(dir, `logs-${Date.now()}.md`);
15
+ await writeFile(path, content, 'utf-8');
16
+ return path;
17
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-datadog",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "description": "Datadog log search tools for pi — query production logs with project-aware context",
5
5
  "keywords": [
6
6
  "pi-package",