@dreki-gg/pi-datadog 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ # @dreki-gg/pi-datadog
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - New Datadog extension — search production logs from within pi with project-aware context via `.pi/datadog.json`.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # @dreki-gg/pi-datadog
2
+
3
+ Datadog log search tools for [pi](https://github.com/earendil-works/pi) — query production logs with project-aware context.
4
+
5
+ ## Setup
6
+
7
+ ### 1. Install
8
+
9
+ ```bash
10
+ pi install npm:@dreki-gg/pi-datadog
11
+ ```
12
+
13
+ ### 2. Set credentials
14
+
15
+ Export your Datadog API and Application keys as environment variables:
16
+
17
+ ```bash
18
+ export DD_API_KEY="your-datadog-api-key"
19
+ export DD_APP_KEY="your-datadog-app-key"
20
+ ```
21
+
22
+ > **Tip:** Add these to your shell profile (`~/.zshrc`, `~/.bashrc`) or use a `.env` manager. Never commit credentials to version control.
23
+
24
+ You need an **Application Key** (not just an API key) because log search uses Datadog's read endpoints.
25
+
26
+ ### 3. Configure your project (optional)
27
+
28
+ Create `.pi/datadog.json` in your project root to set defaults:
29
+
30
+ ```json
31
+ {
32
+ "service": "my-api",
33
+ "env": "production",
34
+ "site": "datadoghq.com",
35
+ "defaultTags": ["team:backend"],
36
+ "defaultTimeRange": "1h"
37
+ }
38
+ ```
39
+
40
+ | Field | Description | Default |
41
+ |-------|-------------|---------|
42
+ | `service` | Default service name for queries | _(none)_ |
43
+ | `env` | Default environment | _(none)_ |
44
+ | `site` | Datadog site | `datadoghq.com` |
45
+ | `defaultTags` | Tags auto-appended to every query | `[]` |
46
+ | `defaultTimeRange` | Default lookback window | `1h` |
47
+
48
+ **Supported sites:** `datadoghq.com` (US1), `us3.datadoghq.com`, `us5.datadoghq.com`, `datadoghq.eu` (EU).
49
+
50
+ ## Usage
51
+
52
+ ### Natural language
53
+
54
+ Just ask pi to search your logs:
55
+
56
+ ```
57
+ > Show me the errors in production from the last 30 minutes
58
+ > What's causing the 500 errors on the payments service?
59
+ > Find logs with "timeout" in staging from the past 24 hours
60
+ ```
61
+
62
+ 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
+
64
+ ### Tool: `datadog_logs_search`
65
+
66
+ The agent calls this tool with [Datadog query syntax](https://docs.datadoghq.com/logs/explorer/search_syntax/):
67
+
68
+ | Parameter | Type | Description |
69
+ |-----------|------|-------------|
70
+ | `query` | `string` | **Required.** Datadog log query (e.g. `status:error`, `@http.status_code:500`) |
71
+ | `from` | `string` | Start time — relative (`15m`, `1h`, `7d`) or ISO 8601 |
72
+ | `to` | `string` | End time — relative, ISO 8601, or `now` |
73
+ | `limit` | `number` | Max results (1–100, default 25) |
74
+ | `sort` | `string` | `newest` or `oldest` |
75
+ | `service` | `string` | Override project default service |
76
+ | `env` | `string` | Override project default environment |
77
+
78
+ ### Command: `/datadog`
79
+
80
+ Check your configuration and connection status:
81
+
82
+ ```
83
+ /datadog
84
+ ```
85
+
86
+ Shows: credential status (set/missing), project config (service, env, site, tags).
87
+
88
+ ## Query Syntax Examples
89
+
90
+ ```
91
+ status:error # All errors
92
+ service:my-api status:error # Errors for a specific service
93
+ @http.status_code:500 # 500 errors
94
+ "connection refused" # Full-text search
95
+ service:my-api env:production @duration:>5000 # Slow requests
96
+ ```
97
+
98
+ See the [Datadog Log Search Syntax docs](https://docs.datadoghq.com/logs/explorer/search_syntax/) for the full reference.
99
+
100
+ ## How It Works
101
+
102
+ 1. The extension loads `.pi/datadog.json` from your project on session start
103
+ 2. When the agent calls `datadog_logs_search`, it merges your query with project defaults (service, env, tags)
104
+ 3. Results are formatted as markdown with status icons, truncated messages, and key attributes
105
+ 4. The agent receives structured metadata (status breakdown, services found) to summarize intelligently
106
+
107
+ ## License
108
+
109
+ MIT
@@ -0,0 +1,161 @@
1
+ import { client, v2 } from '@datadog/datadog-api-client';
2
+ import type { DatadogCredentials, DatadogProjectConfig } from './config.js';
3
+
4
+ export interface LogSearchParams {
5
+ query: string;
6
+ from?: string;
7
+ to?: string;
8
+ limit?: number;
9
+ sort?: 'newest' | 'oldest';
10
+ service?: string;
11
+ env?: string;
12
+ }
13
+
14
+ export interface LogEntry {
15
+ id: string;
16
+ timestamp: string;
17
+ status: string;
18
+ service: string;
19
+ host: string;
20
+ message: string;
21
+ tags: string[];
22
+ attributes: Record<string, unknown>;
23
+ }
24
+
25
+ export interface LogSearchResult {
26
+ logs: LogEntry[];
27
+ totalCount: number;
28
+ query: string;
29
+ from: string;
30
+ to: string;
31
+ cursor?: string;
32
+ }
33
+
34
+ /**
35
+ * Parses a relative time string (e.g. "15m", "1h", "7d") into a Date.
36
+ * Returns null if the string is not a recognized relative format.
37
+ */
38
+ export function parseRelativeTime(input: string): Date | null {
39
+ const match = input.match(/^(\d+)([mhd])$/);
40
+ if (!match) return null;
41
+
42
+ const amount = Number(match[1]);
43
+ const unit = match[2];
44
+
45
+ const multipliers: Record<string, number> = {
46
+ m: 60 * 1000,
47
+ h: 60 * 60 * 1000,
48
+ d: 24 * 60 * 60 * 1000,
49
+ };
50
+
51
+ return new Date(Date.now() - amount * multipliers[unit]);
52
+ }
53
+
54
+ /**
55
+ * Resolves a time input to a Date.
56
+ * Accepts: relative ("15m", "1h"), ISO 8601, or "now".
57
+ */
58
+ export function resolveTime(input: string): Date {
59
+ if (input === 'now') return new Date();
60
+
61
+ const relative = parseRelativeTime(input);
62
+ if (relative) return relative;
63
+
64
+ const date = new Date(input);
65
+ if (Number.isNaN(date.getTime())) {
66
+ throw new Error(
67
+ `Invalid time format: "${input}". Use relative (15m, 1h, 7d), ISO 8601, or "now".`,
68
+ );
69
+ }
70
+ return date;
71
+ }
72
+
73
+ /**
74
+ * Builds the full Datadog query by merging the user query with config defaults.
75
+ */
76
+ export function buildQuery(params: LogSearchParams, config: DatadogProjectConfig): string {
77
+ const parts: string[] = [params.query];
78
+
79
+ const service = params.service ?? config.service;
80
+ if (service && !params.query.includes('service:')) {
81
+ parts.push(`service:${service}`);
82
+ }
83
+
84
+ const env = params.env ?? config.env;
85
+ if (env && !params.query.includes('env:')) {
86
+ parts.push(`env:${env}`);
87
+ }
88
+
89
+ if (config.defaultTags) {
90
+ for (const tag of config.defaultTags) {
91
+ const tagKey = tag.split(':')[0];
92
+ if (!params.query.includes(`${tagKey}:`)) {
93
+ parts.push(tag);
94
+ }
95
+ }
96
+ }
97
+
98
+ return parts.join(' ');
99
+ }
100
+
101
+ function createApiInstance(credentials: DatadogCredentials, site: string): v2.LogsApi {
102
+ const configuration = client.createConfiguration({
103
+ authMethods: {
104
+ apiKeyAuth: credentials.apiKey,
105
+ appKeyAuth: credentials.appKey,
106
+ },
107
+ });
108
+ configuration.setServerVariables({ site });
109
+
110
+ return new v2.LogsApi(configuration);
111
+ }
112
+
113
+ function normalizeLogEntry(log: v2.Log): LogEntry {
114
+ const attrs = log.attributes;
115
+ return {
116
+ id: log.id ?? 'unknown',
117
+ timestamp: attrs?.timestamp?.toISOString() ?? 'unknown',
118
+ status: attrs?.status ?? 'unknown',
119
+ service: attrs?.service ?? 'unknown',
120
+ host: attrs?.host ?? 'unknown',
121
+ message: attrs?.message ?? '',
122
+ tags: attrs?.tags ?? [],
123
+ attributes: (attrs?.attributes as Record<string, unknown>) ?? {},
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Searches Datadog logs using the v2 API.
129
+ */
130
+ export async function searchLogs(
131
+ params: LogSearchParams,
132
+ config: DatadogProjectConfig,
133
+ credentials: DatadogCredentials,
134
+ ): Promise<LogSearchResult> {
135
+ const fullQuery = buildQuery(params, config);
136
+ const fromTime = resolveTime(params.from ?? config.defaultTimeRange);
137
+ const toTime = resolveTime(params.to ?? 'now');
138
+ const limit = Math.min(params.limit ?? 25, 100);
139
+ const sortOrder = params.sort === 'oldest' ? 'timestamp' : '-timestamp';
140
+
141
+ const api = createApiInstance(credentials, config.site);
142
+
143
+ const response = await api.listLogsGet({
144
+ filterQuery: fullQuery,
145
+ filterFrom: fromTime,
146
+ filterTo: toTime,
147
+ pageLimit: limit,
148
+ sort: sortOrder as v2.LogsSort,
149
+ });
150
+
151
+ const logs = (response.data ?? []).map(normalizeLogEntry);
152
+
153
+ return {
154
+ logs,
155
+ totalCount: logs.length,
156
+ query: fullQuery,
157
+ from: fromTime.toISOString(),
158
+ to: toTime.toISOString(),
159
+ cursor: response.meta?.page?.after,
160
+ };
161
+ }
@@ -0,0 +1,124 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ export interface DatadogProjectConfig {
5
+ service?: string;
6
+ env?: string;
7
+ site: string;
8
+ defaultTags?: string[];
9
+ defaultTimeRange: string;
10
+ }
11
+
12
+ interface RawConfig {
13
+ service?: unknown;
14
+ env?: unknown;
15
+ site?: unknown;
16
+ defaultTags?: unknown;
17
+ defaultTimeRange?: unknown;
18
+ }
19
+
20
+ const DEFAULT_CONFIG: DatadogProjectConfig = {
21
+ site: 'datadoghq.com',
22
+ defaultTimeRange: '1h',
23
+ };
24
+
25
+ export interface DatadogCredentials {
26
+ apiKey: string;
27
+ appKey: string;
28
+ }
29
+
30
+ /**
31
+ * Reads credentials from environment variables.
32
+ * Returns null if either key is missing.
33
+ */
34
+ export function getCredentials(): DatadogCredentials | null {
35
+ const apiKey = process.env.DD_API_KEY;
36
+ const appKey = process.env.DD_APP_KEY;
37
+
38
+ if (!apiKey || !appKey) return null;
39
+
40
+ return { apiKey, appKey };
41
+ }
42
+
43
+ /**
44
+ * Checks which credential keys are present (without exposing values).
45
+ */
46
+ export function getCredentialStatus(): { hasApiKey: boolean; hasAppKey: boolean } {
47
+ return {
48
+ hasApiKey: Boolean(process.env.DD_API_KEY),
49
+ hasAppKey: Boolean(process.env.DD_APP_KEY),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Loads and validates .pi/datadog.json from the project root.
55
+ * Returns defaults if the file doesn't exist.
56
+ * Throws on invalid JSON or invalid field types.
57
+ */
58
+ export async function loadProjectConfig(cwd: string): Promise<DatadogProjectConfig> {
59
+ const configPath = join(cwd, '.pi', 'datadog.json');
60
+
61
+ let raw: string;
62
+ try {
63
+ raw = await readFile(configPath, 'utf-8');
64
+ } catch (err: unknown) {
65
+ if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
66
+ return { ...DEFAULT_CONFIG };
67
+ }
68
+ throw new Error(`Failed to read ${configPath}: ${(err as Error).message}`);
69
+ }
70
+
71
+ let parsed: RawConfig;
72
+ try {
73
+ parsed = JSON.parse(raw) as RawConfig;
74
+ } catch {
75
+ throw new Error(`Invalid JSON in ${configPath}`);
76
+ }
77
+
78
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
79
+ throw new Error(`${configPath} must be a JSON object`);
80
+ }
81
+
82
+ return validateConfig(parsed, configPath);
83
+ }
84
+
85
+ function validateConfig(raw: RawConfig, configPath: string): DatadogProjectConfig {
86
+ const config: DatadogProjectConfig = { ...DEFAULT_CONFIG };
87
+
88
+ if (raw.service !== undefined) {
89
+ if (typeof raw.service !== 'string') {
90
+ throw new Error(`${configPath}: "service" must be a string`);
91
+ }
92
+ config.service = raw.service;
93
+ }
94
+
95
+ if (raw.env !== undefined) {
96
+ if (typeof raw.env !== 'string') {
97
+ throw new Error(`${configPath}: "env" must be a string`);
98
+ }
99
+ config.env = raw.env;
100
+ }
101
+
102
+ if (raw.site !== undefined) {
103
+ if (typeof raw.site !== 'string') {
104
+ throw new Error(`${configPath}: "site" must be a string`);
105
+ }
106
+ config.site = raw.site;
107
+ }
108
+
109
+ if (raw.defaultTags !== undefined) {
110
+ if (!Array.isArray(raw.defaultTags) || !raw.defaultTags.every((t) => typeof t === 'string')) {
111
+ throw new Error(`${configPath}: "defaultTags" must be an array of strings`);
112
+ }
113
+ config.defaultTags = raw.defaultTags as string[];
114
+ }
115
+
116
+ if (raw.defaultTimeRange !== undefined) {
117
+ if (typeof raw.defaultTimeRange !== 'string') {
118
+ throw new Error(`${configPath}: "defaultTimeRange" must be a string`);
119
+ }
120
+ config.defaultTimeRange = raw.defaultTimeRange;
121
+ }
122
+
123
+ return config;
124
+ }
@@ -0,0 +1,115 @@
1
+ import type { LogEntry, LogSearchResult } from './client.js';
2
+
3
+ const MAX_MESSAGE_LENGTH = 500;
4
+ const MAX_ATTRIBUTES_LENGTH = 300;
5
+
6
+ /**
7
+ * Truncates a string to the given max length, appending "…" if truncated.
8
+ */
9
+ function truncate(text: string, maxLength: number): string {
10
+ if (text.length <= maxLength) return text;
11
+ return `${text.slice(0, maxLength - 1)}…`;
12
+ }
13
+
14
+ /**
15
+ * Formats a single log entry as a compact markdown block.
16
+ */
17
+ function formatLogEntry(log: LogEntry, index: number): string {
18
+ const statusIcon = getStatusIcon(log.status);
19
+ const header = `### ${index + 1}. ${statusIcon} \`${log.status}\` — ${log.timestamp}`;
20
+
21
+ const lines: string[] = [header];
22
+
23
+ if (log.service !== 'unknown') lines.push(`**Service:** ${log.service}`);
24
+ if (log.host !== 'unknown') lines.push(`**Host:** ${log.host}`);
25
+
26
+ if (log.message) {
27
+ lines.push(`**Message:**\n\`\`\`\n${truncate(log.message, MAX_MESSAGE_LENGTH)}\n\`\`\``);
28
+ }
29
+
30
+ const attrKeys = Object.keys(log.attributes);
31
+ if (attrKeys.length > 0) {
32
+ const attrStr = truncate(JSON.stringify(log.attributes, null, 2), MAX_ATTRIBUTES_LENGTH);
33
+ lines.push(`**Attributes:**\n\`\`\`json\n${attrStr}\n\`\`\``);
34
+ }
35
+
36
+ if (log.tags.length > 0) {
37
+ lines.push(`**Tags:** ${log.tags.map((t) => `\`${t}\``).join(', ')}`);
38
+ }
39
+
40
+ return lines.join('\n');
41
+ }
42
+
43
+ function getStatusIcon(status: string): string {
44
+ const lower = status.toLowerCase();
45
+ if (lower === 'error' || lower === 'critical' || lower === 'emergency' || lower === 'alert') {
46
+ return '🔴';
47
+ }
48
+ if (lower === 'warn' || lower === 'warning') return '🟡';
49
+ if (lower === 'info' || lower === 'notice') return '🔵';
50
+ return '⚪';
51
+ }
52
+
53
+ /**
54
+ * Formats the full search result for LLM consumption.
55
+ */
56
+ export function formatSearchResult(result: LogSearchResult): string {
57
+ const lines: string[] = [];
58
+
59
+ lines.push(`## Datadog Log Search Results`);
60
+ lines.push('');
61
+ lines.push(`**Query:** \`${result.query}\``);
62
+ lines.push(`**Time range:** ${result.from} → ${result.to}`);
63
+ lines.push(`**Results:** ${result.totalCount} logs returned`);
64
+
65
+ if (result.cursor) {
66
+ lines.push(`**Pagination:** More results available (cursor present)`);
67
+ }
68
+
69
+ lines.push('');
70
+
71
+ if (result.logs.length === 0) {
72
+ lines.push('No logs found matching the query.');
73
+ return lines.join('\n');
74
+ }
75
+
76
+ lines.push('---');
77
+ lines.push('');
78
+
79
+ for (let i = 0; i < result.logs.length; i++) {
80
+ lines.push(formatLogEntry(result.logs[i], i));
81
+ if (i < result.logs.length - 1) lines.push('');
82
+ }
83
+
84
+ return lines.join('\n');
85
+ }
86
+
87
+ /**
88
+ * Formats a summary of the search result for tool details.
89
+ */
90
+ export function formatSearchSummary(result: LogSearchResult): {
91
+ totalCount: number;
92
+ query: string;
93
+ timeRange: { from: string; to: string };
94
+ statusBreakdown: Record<string, number>;
95
+ services: string[];
96
+ hasCursor: boolean;
97
+ } {
98
+ const statusBreakdown: Record<string, number> = {};
99
+ const services = new Set<string>();
100
+
101
+ for (const log of result.logs) {
102
+ const status = log.status.toLowerCase();
103
+ statusBreakdown[status] = (statusBreakdown[status] ?? 0) + 1;
104
+ if (log.service !== 'unknown') services.add(log.service);
105
+ }
106
+
107
+ return {
108
+ totalCount: result.totalCount,
109
+ query: result.query,
110
+ timeRange: { from: result.from, to: result.to },
111
+ statusBreakdown,
112
+ services: [...services],
113
+ hasCursor: Boolean(result.cursor),
114
+ };
115
+ }
@@ -0,0 +1,192 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { Type } from 'typebox';
3
+ import { StringEnum } from '@earendil-works/pi-ai';
4
+ import {
5
+ type DatadogProjectConfig,
6
+ getCredentials,
7
+ getCredentialStatus,
8
+ loadProjectConfig,
9
+ } from './config.js';
10
+ import { searchLogs } from './client.js';
11
+ import { formatSearchResult, formatSearchSummary } from './format.js';
12
+
13
+ const SORT_ENUM = ['newest', 'oldest'] as const;
14
+
15
+ const TOOL_GUIDELINES = [
16
+ '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
+ '`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
+ 'When `datadog_logs_search` returns many results, summarize the patterns (error types, frequency, affected services) instead of listing every log entry.',
19
+ 'Use appropriate time ranges with `datadog_logs_search`: "15m" for recent issues, "1h" for general debugging, "24h" or "7d" for trend analysis.',
20
+ ];
21
+
22
+ export default function datadogExtension(pi: ExtensionAPI) {
23
+ let projectConfig: DatadogProjectConfig | null = null;
24
+
25
+ pi.on('session_start', async (_event, ctx) => {
26
+ try {
27
+ projectConfig = await loadProjectConfig(ctx.cwd);
28
+ } catch (err) {
29
+ ctx.ui.notify(`Datadog config error: ${(err as Error).message}`, 'warning');
30
+ projectConfig = null;
31
+ }
32
+ });
33
+
34
+ pi.registerTool({
35
+ name: 'datadog_logs_search',
36
+ label: 'Datadog Log Search',
37
+ description:
38
+ 'Search Datadog logs with query syntax. Uses project defaults from .pi/datadog.json for service, environment, and time range.',
39
+ promptSnippet: 'Search Datadog logs with query syntax and project-aware defaults',
40
+ promptGuidelines: TOOL_GUIDELINES,
41
+ parameters: Type.Object({
42
+ query: Type.String({
43
+ description:
44
+ 'Datadog log query syntax (e.g. "status:error", "@http.status_code:500", "error connecting to database")',
45
+ }),
46
+ from: Type.Optional(
47
+ Type.String({
48
+ description:
49
+ 'Start time — relative (15m, 1h, 7d) or ISO 8601. Defaults to project config or 1h.',
50
+ }),
51
+ ),
52
+ to: Type.Optional(
53
+ Type.String({
54
+ description: 'End time — relative, ISO 8601, or "now". Defaults to "now".',
55
+ }),
56
+ ),
57
+ limit: Type.Optional(
58
+ Type.Number({
59
+ description: 'Max logs to return (1-100). Default 25.',
60
+ minimum: 1,
61
+ maximum: 100,
62
+ }),
63
+ ),
64
+ sort: Type.Optional(
65
+ StringEnum(SORT_ENUM, {
66
+ description: 'Sort order by timestamp. Default "newest".',
67
+ }),
68
+ ),
69
+ service: Type.Optional(
70
+ Type.String({
71
+ description: 'Service name — overrides project default from .pi/datadog.json.',
72
+ }),
73
+ ),
74
+ env: Type.Optional(
75
+ Type.String({
76
+ description: 'Environment — overrides project default from .pi/datadog.json.',
77
+ }),
78
+ ),
79
+ }),
80
+
81
+ async execute(
82
+ _toolCallId: string,
83
+ params: {
84
+ query: string;
85
+ from?: string;
86
+ to?: string;
87
+ limit?: number;
88
+ sort?: (typeof SORT_ENUM)[number];
89
+ service?: string;
90
+ env?: string;
91
+ },
92
+ _signal?: AbortSignal,
93
+ _onUpdate?: unknown,
94
+ ctx?: { cwd: string },
95
+ ) {
96
+ const credentials = getCredentials();
97
+ if (!credentials) {
98
+ const status = getCredentialStatus();
99
+ const missing = [
100
+ !status.hasApiKey && 'DD_API_KEY',
101
+ !status.hasAppKey && 'DD_APP_KEY',
102
+ ].filter(Boolean);
103
+
104
+ return {
105
+ content: [
106
+ {
107
+ type: 'text' as const,
108
+ text: `❌ Missing Datadog credentials: ${missing.join(', ')}.\n\nSet these environment variables to enable Datadog log search.`,
109
+ },
110
+ ],
111
+ details: { error: 'missing_credentials', missing } as Record<string, unknown>,
112
+ isError: true,
113
+ };
114
+ }
115
+
116
+ // Reload config if not loaded yet (e.g. tool called before session_start)
117
+ if (!projectConfig && ctx?.cwd) {
118
+ try {
119
+ projectConfig = await loadProjectConfig(ctx.cwd);
120
+ } catch {
121
+ // Use defaults
122
+ projectConfig = { site: 'datadoghq.com', defaultTimeRange: '1h' };
123
+ }
124
+ }
125
+
126
+ const config = projectConfig ?? { site: 'datadoghq.com', defaultTimeRange: '1h' };
127
+
128
+ try {
129
+ const result = await searchLogs(params, config, credentials);
130
+ const formatted = formatSearchResult(result);
131
+ const summary = formatSearchSummary(result);
132
+
133
+ return {
134
+ content: [{ type: 'text' as const, text: formatted }],
135
+ details: summary as Record<string, unknown>,
136
+ };
137
+ } catch (err) {
138
+ const message = err instanceof Error ? err.message : String(err);
139
+ return {
140
+ content: [
141
+ {
142
+ type: 'text' as const,
143
+ text: `❌ Datadog API error: ${message}`,
144
+ },
145
+ ],
146
+ details: { error: 'api_error', message } as Record<string, unknown>,
147
+ isError: true,
148
+ };
149
+ }
150
+ },
151
+ });
152
+
153
+ pi.registerCommand('datadog', {
154
+ description: 'Show Datadog configuration and connection status',
155
+ handler: async (
156
+ _args: string,
157
+ ctx: {
158
+ cwd: string;
159
+ hasUI: boolean;
160
+ ui: { notify(message: string, level: 'info' | 'warning' | 'error'): void };
161
+ },
162
+ ) => {
163
+ const credStatus = getCredentialStatus();
164
+ const config = projectConfig ?? (await loadProjectConfig(ctx.cwd).catch(() => null));
165
+
166
+ const lines: string[] = ['Datadog Extension Status', ''];
167
+
168
+ // Credentials
169
+ lines.push(`API Key: ${credStatus.hasApiKey ? '✅ Set' : '❌ Missing (DD_API_KEY)'}`);
170
+ lines.push(`App Key: ${credStatus.hasAppKey ? '✅ Set' : '❌ Missing (DD_APP_KEY)'}`);
171
+ lines.push('');
172
+
173
+ // Config
174
+ if (config) {
175
+ lines.push('Project Config (.pi/datadog.json):');
176
+ lines.push(` Site: ${config.site}`);
177
+ lines.push(` Service: ${config.service ?? '(not set)'}`);
178
+ lines.push(` Env: ${config.env ?? '(not set)'}`);
179
+ lines.push(` Default time range: ${config.defaultTimeRange}`);
180
+ if (config.defaultTags?.length) {
181
+ lines.push(` Default tags: ${config.defaultTags.join(', ')}`);
182
+ }
183
+ } else {
184
+ lines.push('No .pi/datadog.json found — using defaults.');
185
+ }
186
+
187
+ if (ctx.hasUI) {
188
+ ctx.ui.notify(lines.join('\n'), 'info');
189
+ }
190
+ },
191
+ });
192
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@dreki-gg/pi-datadog",
3
+ "version": "0.2.0",
4
+ "description": "Datadog log search tools for pi — query production logs with project-aware context",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "datadog",
9
+ "logs",
10
+ "observability"
11
+ ],
12
+ "author": "Juan Albarran <jalbarrandev@gmail.com>",
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/dreki-gg/pi-extensions",
17
+ "directory": "packages/datadog"
18
+ },
19
+ "type": "module",
20
+ "files": [
21
+ "extensions",
22
+ "README.md",
23
+ "CHANGELOG.md",
24
+ "package.json"
25
+ ],
26
+ "scripts": {
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "bun test",
29
+ "lint": "oxlint extensions test",
30
+ "format": "oxfmt --write extensions test",
31
+ "format:check": "oxfmt --check extensions test"
32
+ },
33
+ "pi": {
34
+ "extensions": [
35
+ "./extensions/datadog"
36
+ ]
37
+ },
38
+ "dependencies": {
39
+ "@datadog/datadog-api-client": "^1.34.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "24",
43
+ "bun-types": "^1.3.14",
44
+ "oxfmt": "^0.43.0",
45
+ "oxlint": "^1.58.0",
46
+ "typescript": "^6.0.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-coding-agent": "*",
50
+ "typebox": "*"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@earendil-works/pi-coding-agent": {
54
+ "optional": true
55
+ },
56
+ "typebox": {
57
+ "optional": true
58
+ }
59
+ }
60
+ }