Skip to content
Merged
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
1,957 changes: 1,927 additions & 30 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"typescript": "5.8.3"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "3.997.0",
"@aws-sdk/client-iam": "3.997.0",
"@aws-sdk/client-lambda": "3.997.0",
"@aws-sdk/client-sts": "3.997.0",
"@inquirer/prompts": "8.3.0",
"commander": "12.1.0",
"open": "10.1.0"
}
Expand Down
54 changes: 53 additions & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async function apiCall<T>(method: string, body?: unknown): Promise<ApiResponse<T

if (!config.authToken) {

return {ok: false, error: 'Not logged in. Run: logspace login'};
return {ok: false, error: 'Not logged in. Run: logfox login'};

}

Expand Down Expand Up @@ -110,6 +110,8 @@ export type LogEntry = {
data?: Record<string, unknown>
};

export type Collector = 'cli' | 'cloudwatch-logs' | 'sdk' | 'vercel' | 'fluentbit';

export async function ingestLogs(teamId: string, appId: string, env: string, logs: LogEntry[]): Promise<ApiResponse<{
success: boolean
logsIngested: number
Expand All @@ -118,3 +120,53 @@ export async function ingestLogs(teamId: string, appId: string, env: string, log
return apiCall('ingestLogs', {teamId, appId, env, logs});

}

/**
* Ingest logs via the new /v1/ingest endpoint using API key authentication.
* This is the preferred method for all log ingestion going forward.
*/
export async function ingestLogsV1(
apiKey: string,
appId: string,
env: string,
collector: Collector,
logs: LogEntry[],
): Promise<ApiResponse<{success: boolean; logsIngested: number}>> {

const config = getConfig();

try {

const response = await fetch(`${config.apiUrl}/v1/ingest`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({appId, env, collector, logs}),
});

if (!response.ok) {

const text = await response.text();
return {ok: false, error: `API error: ${response.status} ${text}`};

}

const data = await response.json() as {success: boolean; logsIngested: number; error?: string};

if ('error' in data && data.error) {

return {ok: false, error: data.error};

}

return {ok: true, data};

} catch (err) {

return {ok: false, error: `Request failed: ${err}`};

}

}
218 changes: 218 additions & 0 deletions src/commands/cloudwatch-remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import {STSClient, GetCallerIdentityCommand} from '@aws-sdk/client-sts';
import {
CloudWatchLogsClient,
DescribeLogGroupsCommand,
DescribeSubscriptionFiltersCommand,
DeleteSubscriptionFilterCommand,
SubscriptionFilter,
} from '@aws-sdk/client-cloudwatch-logs';
import {LambdaClient, GetFunctionCommand} from '@aws-sdk/client-lambda';
import {checkbox, confirm} from '@inquirer/prompts';

const FORWARDER_LAMBDA_NAME = 'LogfoxForwarder';

interface LogfoxFilter {
logGroupName: string;
filterName: string;
}

export async function cloudwatchRemove(): Promise<void> {

console.log('Logfox CloudWatch Remove');
console.log('========================');
console.log();

// 1. Check AWS credentials
console.log('Checking AWS credentials...');

const sts = new STSClient({});
let region: string;

try {

const identity = await sts.send(new GetCallerIdentityCommand({}));
region = await sts.config.region() as string;
console.log(`✓ Using AWS account ${identity.Account} (${region})`);

} catch {

console.error('✗ Failed to get AWS credentials.');
console.error(' Make sure you have AWS credentials configured.');
console.error(' Run: aws configure');
process.exit(1);

}

console.log();

// 2. Get Lambda ARN
console.log('Looking for Logfox Forwarder Lambda...');

const lambdaClient = new LambdaClient({});
let lambdaArn: string | undefined;

try {

const lambda = await lambdaClient.send(new GetFunctionCommand({
FunctionName: FORWARDER_LAMBDA_NAME,
}));

lambdaArn = lambda.Configuration?.FunctionArn;
console.log(`✓ Found Lambda: ${FORWARDER_LAMBDA_NAME}`);

} catch {

console.log('⚠ Lambda not found. Will search for subscription filters by name.');

}

console.log();

// 3. Find all subscription filters pointing to Logfox
console.log('Scanning for Logfox subscription filters...');

const logsClient = new CloudWatchLogsClient({});
const logfoxFilters: LogfoxFilter[] = [];

// Get all log groups
let nextToken: string | undefined;

do {

const logGroupsResponse = await logsClient.send(new DescribeLogGroupsCommand({nextToken}));

for (const logGroup of logGroupsResponse.logGroups || []) {

if (!logGroup.logGroupName) continue;

try {

const filtersResponse = await logsClient.send(new DescribeSubscriptionFiltersCommand({
logGroupName: logGroup.logGroupName,
}));

for (const filter of filtersResponse.subscriptionFilters || []) {

if (isLogfoxFilter(filter, lambdaArn)) {

logfoxFilters.push({
logGroupName: logGroup.logGroupName,
filterName: filter.filterName!,
});

}

}

} catch {
// Ignore errors for individual log groups
}

}

nextToken = logGroupsResponse.nextToken;

} while (nextToken);

if (logfoxFilters.length === 0) {

console.log('No Logfox subscription filters found.');
console.log('Nothing to remove.');
process.exit(0);

}

console.log(`Found ${logfoxFilters.length} Logfox subscription filter(s)`);
console.log();

// 4. Let user select which to remove
const toRemove = await checkbox({
message: 'Select subscription filters to remove:',
choices: logfoxFilters.map((f) => ({
name: f.logGroupName,
value: f,
checked: true,
})),
pageSize: 15,
});

if (toRemove.length === 0) {

console.log('No filters selected. Exiting.');
process.exit(0);

}

console.log();

// 5. Confirm
const proceed = await confirm({
message: `Remove ${toRemove.length} subscription filter(s)?`,
default: true,
});

if (!proceed) {

console.log('Cancelled.');
process.exit(0);

}

console.log();

// 6. Delete selected filters
console.log('Removing subscription filters...');

for (const filter of toRemove) {

try {

await logsClient.send(new DeleteSubscriptionFilterCommand({
logGroupName: filter.logGroupName,
filterName: filter.filterName,
}));

console.log(`✓ Removed subscription filter for ${filter.logGroupName}`);

} catch (error) {

console.error(`✗ Failed to remove filter for ${filter.logGroupName}:`, error);

}

}

console.log();
console.log('Done! The selected log groups will no longer send logs to Logfox.');
console.log();
console.log('Note: The Logfox Forwarder Lambda is still deployed.');
console.log('To completely remove it, delete the Lambda manually in the AWS Console.');

}

function isLogfoxFilter(filter: SubscriptionFilter, lambdaArn?: string): boolean {

// Check if filter name is 'logfox'
if (filter.filterName === 'logfox') {

return true;

}

// Check if destination is the Logfox Lambda
if (lambdaArn && filter.destinationArn === lambdaArn) {

return true;

}

// Check if destination contains 'LogfoxForwarder'
if (filter.destinationArn?.includes('LogfoxForwarder')) {

return true;

}

return false;

}
21 changes: 15 additions & 6 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ export function showConfig(): void {
console.log(JSON.stringify(config, null, 2));
console.log();
console.log('Commands:');
console.log(' logspace config:get <key> Get a config value');
console.log(' logspace config:set <key> <value> Set a config value');
console.log(' logspace config:reset Reset to defaults');
console.log(' logfox config:get <key> Get a config value');
console.log(' logfox config:set <key> <value> Set a config value');
console.log(' logfox config:reset Reset to defaults');
console.log();
console.log('Valid keys: apiUrl, appUrl, teamId');
console.log('Valid keys: apiUrl, appUrl, teamId, apiKey');

}

Expand All @@ -34,14 +34,23 @@ export function getConfigValue(key: string): void {

export async function setConfig(key: string, value: string): Promise<void> {

if (key !== 'apiUrl' && key !== 'appUrl' && key !== 'teamId') {
if (key !== 'apiUrl' && key !== 'appUrl' && key !== 'teamId' && key !== 'apiKey') {

console.error(`Invalid config key: ${key}`);
console.log('Valid keys: apiUrl, appUrl, teamId');
console.log('Valid keys: apiUrl, appUrl, teamId, apiKey');
process.exit(1);

}

// If setting apiKey, just save it directly
if (key === 'apiKey') {

saveConfig({apiKey: value});
console.log('API key saved.');
return;

}

// If setting teamId, also fetch and save the team name
if (key === 'teamId') {

Expand Down
6 changes: 3 additions & 3 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export async function login(): Promise<void> {
if (result.ok) {

console.log(`Already logged in as ${result.data.email}`);
console.log('Run "logspace logout" to sign out first.');
console.log('Run "logfox logout" to sign out first.');
return;

}
Expand Down Expand Up @@ -76,7 +76,7 @@ export async function login(): Promise<void> {

if (teamsResult.data.length > 1) {

console.log(`(You have ${teamsResult.data.length} teams. Run "logspace teams" to see all.)`);
console.log(`(You have ${teamsResult.data.length} teams. Run "logfox teams" to see all.)`);

}

Expand Down Expand Up @@ -110,7 +110,7 @@ function waitForAuthCallback(): Promise<string | null> {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end(`
<html>
<head><title>Logspace CLI</title></head>
<head><title>Logfox CLI</title></head>
<body style="font-family: system-ui; text-align: center; padding: 50px;">
<h1>Login Successful!</h1>
<p>You can close this window and return to the terminal.</p>
Expand Down
Loading