Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 59 additions & 8 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export class BearerToken {
}

export class API {
private bearerToken: BearerToken | null = null;

/**
* Creates a new API client instance.
*
Expand All @@ -47,32 +49,62 @@ export class API {
return `agentops-ts-sdk/${process.env.npm_package_version || 'unknown'}`;
}

/**
* Set the bearer token for authenticated requests
*/
setBearerToken(token: BearerToken): void {
this.bearerToken = token;
}

/**
* Fetch data from the API using the specified path and method.
*
* @param path - The API endpoint path
* @param method - The HTTP method to use (GET or POST)
* @param body - The request body for POST requests
* @param headers - Additional headers to include in the request
* @returns The parsed JSON response
*/
private async fetch<T>(path: string, method: 'GET' | 'POST', body?: any): Promise<T> {
private async fetch<T>(
path: string,
method: 'GET' | 'POST',
body?: any,
headers?: Record<string, string>
): Promise<T> {
const url = `${this.endpoint}${path}`;
debug(`${method} ${url}`);

const defaultHeaders: Record<string, string> = {
'User-Agent': this.userAgent,
'Content-Type': 'application/json',
};

// Add authorization header if bearer token is available
if (this.bearerToken) {
defaultHeaders['Authorization'] = this.bearerToken.getAuthHeader();
}

// Merge with additional headers
const finalHeaders = { ...defaultHeaders, ...headers };

const response = await fetch(url, {
method: method,
headers: {
'User-Agent': this.userAgent,
'Content-Type': 'application/json',
},
headers: finalHeaders,
body: body ? JSON.stringify(body) : undefined
});

if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
let errorMessage = `Request failed: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors
}
throw new Error(errorMessage);
}

debug(`${response.status}`);
return await response.json() as T;
}

Expand All @@ -84,4 +116,23 @@ export class API {
async authenticate(): Promise<TokenResponse> {
return this.fetch<TokenResponse>('/v3/auth/token', 'POST', { api_key: this.apiKey });
}

/**
* Upload log content to the API.
*
* @param logContent - The log content to upload
* @param traceId - The trace ID to associate with the logs
* @returns A promise that resolves when the upload is complete
*/
async uploadLogFile(logContent: string, traceId: string): Promise<{ id: string }> {
if (!this.bearerToken) {
throw new Error('Authentication required. Bearer token not set.');
}
return this.fetch<{ id: string }>(
'/v4/logs/upload/',
'POST',
logContent,
{ 'Trace-Id': traceId }
);
}
}
46 changes: 45 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Config, LogLevel } from './types';
import { API, TokenResponse, BearerToken } from './api';
import { TracingCore } from './tracing';
import { getGlobalResource } from './attributes';
import { loggingService } from './instrumentation/console-logging/service';

const debug = require('debug')('agentops:client');

Expand Down Expand Up @@ -83,12 +84,20 @@ export class Client {
}
this.api = new API(this.config.apiKey, this.config.apiEndpoint!);

// Get auth token and set it on the API instance
const authToken = await this.getAuthToken();
this.api.setBearerToken(authToken);

// Initialize logging service
loggingService.initialize(this.api);

const resource = await getGlobalResource(this.config.serviceName!);
this.core = new TracingCore(
this.config,
await this.getAuthToken(),
this.registry.getActiveInstrumentors(),
resource
resource,
this
);
this.setupExitHandlers();

Expand Down Expand Up @@ -127,6 +136,9 @@ export class Client {
return;
}

// Disable logging service
loggingService.disable();

if(this.core) {
await this.core.shutdown();
}
Expand All @@ -148,6 +160,13 @@ export class Client {
* @private
*/
private setupExitHandlers(): void {
// beforeExit allows async operations, perfect for flushing traces
process.on('beforeExit', async () => {
if (this.initialized) {
await this.flush();
}
});

process.on('exit', () => this.shutdown());
process.on('SIGINT', () => this.shutdown());
process.on('SIGTERM', () => this.shutdown());
Expand Down Expand Up @@ -180,5 +199,30 @@ export class Client {
return this.authToken;
}

/**
* Upload captured console logs to the AgentOps API.
*
* @param traceId - The trace ID to associate with the logs
* @returns Promise resolving to upload result with ID, or null if no logs to upload
* @throws {Error} When the SDK is not initialized or upload fails
*/
async uploadLogFile(traceId: string): Promise<{ id: string } | null> {
this.ensureInitialized();
return loggingService.uploadLogs(traceId);
}

/**
* Flush all pending trace actions: print URLs and upload logs.
* Call this after execution is complete to see results and upload logs.
*
* @throws {Error} When the SDK is not initialized
*/
async flush(): Promise<void> {
this.ensureInitialized();
if (this.core) {
await this.core.flush();
}
}

}

39 changes: 39 additions & 0 deletions src/instrumentation/console-logging/buffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Simple memory buffer for capturing console logs
*/
export class LogBuffer {
private buffer: string[] = [];

/**
* Append a log entry to the buffer
*/
append(entry: string): void {
const timestamp = new Date().toISOString();
const formattedEntry = `${timestamp} - ${entry}`;
this.buffer.push(formattedEntry);
}

/**
* Get all buffer content as a single string
*/
getContent(): string {
return this.buffer.join('\n');
}

/**
* Clear the buffer
*/
clear(): void {
this.buffer = [];
}

/**
* Check if buffer is empty
*/
isEmpty(): boolean {
return this.buffer.length === 0;
}
}

// Global log buffer instance
export const globalLogBuffer = new LogBuffer();
109 changes: 109 additions & 0 deletions src/instrumentation/console-logging/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { InstrumentationBase } from '../base';
import { InstrumentorMetadata } from '../../types';
import { globalLogBuffer } from './buffer';
import { loggingService } from './service';

const debug = require('debug')('agentops:instrumentation:console-logging');

export class ConsoleLoggingInstrumentation extends InstrumentationBase {
static readonly metadata: InstrumentorMetadata = {
name: 'console-logging-instrumentation',
version: '1.0.0',
description: 'Instrumentation for console logging capture',
targetLibrary: 'console', // Dummy target since console is global
targetVersions: ['*']
};
static readonly useRuntimeTargeting = true;

private originalMethods: Map<string, Function> = new Map();
private isPatched: boolean = false;

protected setup(moduleExports: any, moduleVersion?: string): any {
this.patch();
return moduleExports;
}

protected teardown(moduleExports: any, moduleVersion?: string): any {
// Export logs before unpatching if we have spans exported
this.exportLogsIfNeeded();
this.unpatch();
return moduleExports;
}

/**
* Patch console methods to capture output to the log buffer
*/
private patch(): void {
if (this.isPatched) {
return;
}

debug('patching console methods');

// List of console methods to patch
const methodsToPatch = ['log', 'info', 'warn', 'error', 'debug'];

methodsToPatch.forEach(method => {
const originalMethod = (console as any)[method];
this.originalMethods.set(method, originalMethod);

// Create a patched version that logs to buffer and calls original
(console as any)[method] = (...args: any[]) => {
// Format the message
const message = args
.map(arg => {
if (typeof arg === 'object') {
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}
return String(arg);
})
.join(' ');

// Add level prefix and append to buffer
const levelPrefix = method.toUpperCase();
globalLogBuffer.append(`${levelPrefix} - ${message}`);

// Call the original method
originalMethod.apply(console, args);
};
});

this.isPatched = true;
}

/**
* Restore original console methods
*/
private unpatch(): void {
if (!this.isPatched) {
return;
}

debug('unpatching console methods');

this.originalMethods.forEach((originalMethod, method) => {
(console as any)[method] = originalMethod;
});

this.originalMethods.clear();
this.isPatched = false;
}

/**
* Export logs if needed during teardown
*/
private exportLogsIfNeeded(): void {
try {
if (!globalLogBuffer.isEmpty()) {
debug('logs available for export during teardown');
// Logs will be uploaded automatically by the flush mechanism
}
} catch (error) {
debug('failed to check logs during teardown:', error);
}
}
}
Loading