Skip to content

Commit 85d01f9

Browse files
ozgesolidkeyclaude
andcommitted
Add Datadog Logs API integration
Connect to Datadog via the Logs Search API, fetch logs with pagination, and open them in LOGAN's existing file viewer. Credentials stored in ~/.logan/datadog.json, API calls run in main process via electron.net. Includes 36 tests covering site mapping, log formatting, config persistence, time range computation, and filename sanitization. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8d5e8cd commit 85d01f9

8 files changed

Lines changed: 1071 additions & 0 deletions

File tree

src/main/datadogClient.ts

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as os from 'os';
4+
import { net } from 'electron';
5+
6+
export interface DatadogConfig {
7+
site: string; // e.g. 'datadoghq.com', 'us3.datadoghq.com', etc.
8+
apiKey: string;
9+
appKey: string;
10+
}
11+
12+
export interface DatadogFetchParams {
13+
query: string;
14+
from: string; // ISO timestamp
15+
to: string; // ISO timestamp
16+
maxLogs: number;
17+
}
18+
19+
export interface DatadogFetchResult {
20+
success: boolean;
21+
filePath?: string;
22+
logCount?: number;
23+
error?: string;
24+
}
25+
26+
const CONFIG_DIR = path.join(os.homedir(), '.logan');
27+
const CONFIG_FILE = path.join(CONFIG_DIR, 'datadog.json');
28+
const DATADOG_DIR = path.join(CONFIG_DIR, 'datadog');
29+
30+
function ensureDir(dir: string): void {
31+
if (!fs.existsSync(dir)) {
32+
fs.mkdirSync(dir, { recursive: true });
33+
}
34+
}
35+
36+
export function loadDatadogConfig(): DatadogConfig | null {
37+
try {
38+
if (fs.existsSync(CONFIG_FILE)) {
39+
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
40+
return JSON.parse(data);
41+
}
42+
} catch (error) {
43+
console.error('Failed to load Datadog config:', error);
44+
}
45+
return null;
46+
}
47+
48+
export function saveDatadogConfig(config: DatadogConfig): void {
49+
ensureDir(CONFIG_DIR);
50+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
51+
}
52+
53+
export function clearDatadogConfig(): void {
54+
try {
55+
if (fs.existsSync(CONFIG_FILE)) {
56+
fs.unlinkSync(CONFIG_FILE);
57+
}
58+
} catch (error) {
59+
console.error('Failed to clear Datadog config:', error);
60+
}
61+
}
62+
63+
// Map site short names to API hostnames
64+
function getSiteHostname(site: string): string {
65+
const siteMap: Record<string, string> = {
66+
'US1': 'datadoghq.com',
67+
'US3': 'us3.datadoghq.com',
68+
'US5': 'us5.datadoghq.com',
69+
'EU1': 'datadoghq.eu',
70+
'AP1': 'ap1.datadoghq.com',
71+
};
72+
return siteMap[site] || site;
73+
}
74+
75+
// Make an HTTP request using Electron's net module
76+
function makeRequest(
77+
url: string,
78+
headers: Record<string, string>,
79+
body: string,
80+
signal: { cancelled: boolean }
81+
): Promise<{ statusCode: number; body: string }> {
82+
return new Promise((resolve, reject) => {
83+
if (signal.cancelled) {
84+
reject(new Error('Cancelled'));
85+
return;
86+
}
87+
88+
const request = net.request({
89+
method: 'POST',
90+
url,
91+
});
92+
93+
for (const [key, value] of Object.entries(headers)) {
94+
request.setHeader(key, value);
95+
}
96+
97+
let responseBody = '';
98+
let statusCode = 0;
99+
100+
request.on('response', (response) => {
101+
statusCode = response.statusCode;
102+
103+
response.on('data', (chunk: Buffer) => {
104+
responseBody += chunk.toString();
105+
});
106+
107+
response.on('end', () => {
108+
resolve({ statusCode, body: responseBody });
109+
});
110+
});
111+
112+
request.on('error', (error) => {
113+
reject(error);
114+
});
115+
116+
request.write(body);
117+
request.end();
118+
});
119+
}
120+
121+
// Format a Datadog log entry into a LOGAN-compatible line
122+
function formatLogLine(log: any): string {
123+
const timestamp = log.attributes?.timestamp || log.attributes?.date || '';
124+
const status = log.attributes?.status || '';
125+
const service = log.attributes?.service || '';
126+
const message = log.attributes?.message || '';
127+
128+
// Build a line compatible with LOGAN's timestamp/level detection
129+
const parts: string[] = [];
130+
if (timestamp) {
131+
// Convert to ISO format if it's a number (epoch ms)
132+
if (typeof timestamp === 'number') {
133+
parts.push(new Date(timestamp).toISOString());
134+
} else {
135+
parts.push(String(timestamp));
136+
}
137+
}
138+
if (status) {
139+
parts.push(`[${String(status).toUpperCase()}]`);
140+
}
141+
if (service) {
142+
parts.push(`${service} -`);
143+
}
144+
if (message) {
145+
parts.push(String(message));
146+
} else {
147+
// Fallback: serialize remaining attributes
148+
const attrs = { ...log.attributes };
149+
delete attrs.timestamp;
150+
delete attrs.date;
151+
delete attrs.status;
152+
delete attrs.service;
153+
delete attrs.message;
154+
if (Object.keys(attrs).length > 0) {
155+
parts.push(JSON.stringify(attrs));
156+
}
157+
}
158+
159+
return parts.join(' ');
160+
}
161+
162+
export async function fetchDatadogLogs(
163+
config: DatadogConfig,
164+
params: DatadogFetchParams,
165+
onProgress: (message: string, count: number) => void,
166+
signal: { cancelled: boolean }
167+
): Promise<DatadogFetchResult> {
168+
const hostname = getSiteHostname(config.site);
169+
const baseUrl = `https://api.${hostname}/api/v2/logs/events/search`;
170+
171+
const headers: Record<string, string> = {
172+
'Content-Type': 'application/json',
173+
'DD-API-KEY': config.apiKey,
174+
'DD-APPLICATION-KEY': config.appKey,
175+
};
176+
177+
const allLogs: string[] = [];
178+
let cursor: string | undefined;
179+
const pageSize = Math.min(params.maxLogs, 1000); // API max per page is 1000
180+
181+
try {
182+
while (allLogs.length < params.maxLogs) {
183+
if (signal.cancelled) {
184+
return { success: false, error: 'Fetch cancelled' };
185+
}
186+
187+
const requestBody: any = {
188+
filter: {
189+
query: params.query,
190+
from: params.from,
191+
to: params.to,
192+
},
193+
sort: 'timestamp',
194+
page: {
195+
limit: Math.min(pageSize, params.maxLogs - allLogs.length),
196+
},
197+
};
198+
199+
if (cursor) {
200+
requestBody.page.cursor = cursor;
201+
}
202+
203+
onProgress(`Fetching logs... (${allLogs.length} so far)`, allLogs.length);
204+
205+
const response = await makeRequest(
206+
baseUrl,
207+
headers,
208+
JSON.stringify(requestBody),
209+
signal
210+
);
211+
212+
if (response.statusCode === 403) {
213+
return { success: false, error: 'Authentication failed: invalid API key or Application key' };
214+
}
215+
if (response.statusCode === 429) {
216+
return { success: false, error: 'Rate limited by Datadog API. Please wait and try again.' };
217+
}
218+
if (response.statusCode !== 200) {
219+
let errorMsg = `Datadog API error (HTTP ${response.statusCode})`;
220+
try {
221+
const errBody = JSON.parse(response.body);
222+
if (errBody.errors) {
223+
errorMsg += `: ${errBody.errors.join(', ')}`;
224+
}
225+
} catch { /* ignore parse errors */ }
226+
return { success: false, error: errorMsg };
227+
}
228+
229+
let data: any;
230+
try {
231+
data = JSON.parse(response.body);
232+
} catch {
233+
return { success: false, error: 'Invalid response from Datadog API' };
234+
}
235+
236+
const logs = data.data || [];
237+
if (logs.length === 0) {
238+
break;
239+
}
240+
241+
for (const log of logs) {
242+
allLogs.push(formatLogLine(log));
243+
if (allLogs.length >= params.maxLogs) break;
244+
}
245+
246+
// Check for next page
247+
cursor = data.meta?.page?.after;
248+
if (!cursor) {
249+
break;
250+
}
251+
}
252+
253+
if (allLogs.length === 0) {
254+
return { success: false, error: 'No logs found for the given query and time range.' };
255+
}
256+
257+
// Write to file
258+
ensureDir(DATADOG_DIR);
259+
const safeQuery = params.query.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 30);
260+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
261+
const fileName = `dd_${safeQuery}_${timestamp}.log`;
262+
const filePath = path.join(DATADOG_DIR, fileName);
263+
264+
onProgress(`Writing ${allLogs.length} logs to file...`, allLogs.length);
265+
fs.writeFileSync(filePath, allLogs.join('\n'), 'utf-8');
266+
267+
return { success: true, filePath, logCount: allLogs.length };
268+
} catch (error: any) {
269+
if (signal.cancelled) {
270+
return { success: false, error: 'Fetch cancelled' };
271+
}
272+
if (error.message?.includes('net::')) {
273+
return { success: false, error: `Network error: ${error.message}` };
274+
}
275+
return { success: false, error: `Error: ${error.message || String(error)}` };
276+
}
277+
}

src/main/index.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as pty from 'node-pty';
77
import { FileHandler, filterLineToVisibleColumns, ColumnConfig } from './fileHandler';
88
import { IPC, SearchOptions, Bookmark, Highlight, HighlightGroup } from '../shared/types';
99
import { analyzerRegistry, AnalyzerOptions } from './analyzers';
10+
import { loadDatadogConfig, saveDatadogConfig, clearDatadogConfig, fetchDatadogLogs, DatadogConfig, DatadogFetchParams } from './datadogClient';
1011

1112
let mainWindow: BrowserWindow | null = null;
1213
let searchSignal: { cancelled: boolean } = { cancelled: false };
@@ -1982,3 +1983,54 @@ ipcMain.handle('terminal-cd', async (_, directory: string) => {
19821983
}
19831984
return { success: false, error: 'No terminal process or invalid directory' };
19841985
});
1986+
1987+
// === Datadog Integration ===
1988+
1989+
let datadogFetchSignal: { cancelled: boolean } = { cancelled: false };
1990+
1991+
ipcMain.handle(IPC.DATADOG_LOAD_CONFIG, async () => {
1992+
const config = loadDatadogConfig();
1993+
if (config) {
1994+
// Return config but mask the keys for display
1995+
return { success: true, config: { site: config.site, hasApiKey: !!config.apiKey, hasAppKey: !!config.appKey } };
1996+
}
1997+
return { success: true, config: null };
1998+
});
1999+
2000+
ipcMain.handle(IPC.DATADOG_SAVE_CONFIG, async (_, config: DatadogConfig | null) => {
2001+
try {
2002+
if (config === null) {
2003+
clearDatadogConfig();
2004+
} else {
2005+
saveDatadogConfig(config);
2006+
}
2007+
return { success: true };
2008+
} catch (error) {
2009+
return { success: false, error: String(error) };
2010+
}
2011+
});
2012+
2013+
ipcMain.handle(IPC.DATADOG_FETCH_LOGS, async (_, params: DatadogFetchParams) => {
2014+
const config = loadDatadogConfig();
2015+
if (!config || !config.apiKey || !config.appKey) {
2016+
return { success: false, error: 'Datadog not configured. Add credentials in Settings.' };
2017+
}
2018+
2019+
datadogFetchSignal = { cancelled: false };
2020+
2021+
const result = await fetchDatadogLogs(
2022+
config,
2023+
params,
2024+
(message, count) => {
2025+
mainWindow?.webContents.send(IPC.DATADOG_FETCH_PROGRESS, { message, count });
2026+
},
2027+
datadogFetchSignal
2028+
);
2029+
2030+
return result;
2031+
});
2032+
2033+
ipcMain.handle(IPC.DATADOG_CANCEL_FETCH, async () => {
2034+
datadogFetchSignal.cancelled = true;
2035+
return { success: true };
2036+
});

src/preload/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const IPC = {
1313
FOLDER_SEARCH: 'folder-search',
1414
FOLDER_SEARCH_PROGRESS: 'folder-search-progress',
1515
FOLDER_SEARCH_CANCEL: 'folder-search-cancel',
16+
DATADOG_LOAD_CONFIG: 'datadog-load-config',
17+
DATADOG_SAVE_CONFIG: 'datadog-save-config',
18+
DATADOG_FETCH_LOGS: 'datadog-fetch-logs',
19+
DATADOG_FETCH_PROGRESS: 'datadog-fetch-progress',
20+
DATADOG_CANCEL_FETCH: 'datadog-cancel-fetch',
1621
} as const;
1722

1823
// API exposed to renderer
@@ -191,6 +196,25 @@ const api = {
191196
formatJsonFile: (filePath: string): Promise<{ success: boolean; formattedPath?: string; error?: string }> =>
192197
ipcRenderer.invoke('format-json-file', filePath),
193198

199+
// Datadog
200+
datadogLoadConfig: (): Promise<{ success: boolean; config?: { site: string; hasApiKey: boolean; hasAppKey: boolean } | null }> =>
201+
ipcRenderer.invoke(IPC.DATADOG_LOAD_CONFIG),
202+
203+
datadogSaveConfig: (config: { site: string; apiKey: string; appKey: string } | null): Promise<{ success: boolean; error?: string }> =>
204+
ipcRenderer.invoke(IPC.DATADOG_SAVE_CONFIG, config),
205+
206+
datadogFetchLogs: (params: { query: string; from: string; to: string; maxLogs: number }): Promise<{ success: boolean; filePath?: string; logCount?: number; error?: string }> =>
207+
ipcRenderer.invoke(IPC.DATADOG_FETCH_LOGS, params),
208+
209+
datadogCancelFetch: (): Promise<{ success: boolean }> =>
210+
ipcRenderer.invoke(IPC.DATADOG_CANCEL_FETCH),
211+
212+
onDatadogFetchProgress: (callback: (data: { message: string; count: number }) => void): (() => void) => {
213+
const handler = (_: any, data: { message: string; count: number }) => callback(data);
214+
ipcRenderer.on(IPC.DATADOG_FETCH_PROGRESS, handler);
215+
return () => ipcRenderer.removeListener(IPC.DATADOG_FETCH_PROGRESS, handler);
216+
},
217+
194218
// Terminal
195219
terminalCreate: (options?: { cwd?: string; cols?: number; rows?: number }): Promise<{ success: boolean; pid?: number; error?: string }> =>
196220
ipcRenderer.invoke('terminal-create', options),

0 commit comments

Comments
 (0)