@logtape/cloudwatch-logs 1.1.0-dev.311 → 1.1.0-dev.315
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/deno.json +1 -1
- package/dist/sink.cjs +1 -62
- package/dist/sink.d.cts.map +1 -1
- package/dist/sink.d.ts.map +1 -1
- package/dist/sink.js +2 -63
- package/dist/sink.js.map +1 -1
- package/dist/types.d.cts +6 -48
- package/dist/types.d.cts.map +1 -1
- package/dist/types.d.ts +6 -48
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/sink.integration.test.ts +0 -211
- package/sink.test.ts +1 -214
- package/sink.ts +1 -105
- package/types.ts +62 -110
package/deno.json
CHANGED
package/dist/sink.cjs
CHANGED
|
@@ -7,53 +7,6 @@ const MAX_BATCH_SIZE_EVENTS = 1e4;
|
|
|
7
7
|
const MAX_BATCH_SIZE_BYTES = 1048576;
|
|
8
8
|
const OVERHEAD_PER_EVENT = 26;
|
|
9
9
|
/**
|
|
10
|
-
* Resolves the log stream name from template.
|
|
11
|
-
* @param logStreamNameTemplate Template for generating stream names
|
|
12
|
-
* @returns Resolved log stream name
|
|
13
|
-
*/
|
|
14
|
-
function resolveLogStreamName(logStreamNameTemplate) {
|
|
15
|
-
const now = /* @__PURE__ */ new Date();
|
|
16
|
-
const year = now.getFullYear().toString();
|
|
17
|
-
const month = (now.getMonth() + 1).toString().padStart(2, "0");
|
|
18
|
-
const day = now.getDate().toString().padStart(2, "0");
|
|
19
|
-
const timestamp = now.getTime().toString();
|
|
20
|
-
return logStreamNameTemplate.replace(/\{YYYY\}/g, year).replace(/\{MM\}/g, month).replace(/\{DD\}/g, day).replace(/\{YYYY-MM-DD\}/g, `${year}-${month}-${day}`).replace(/\{timestamp\}/g, timestamp);
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Ensures that the log stream exists, creating it if necessary.
|
|
24
|
-
* @param client CloudWatch Logs client
|
|
25
|
-
* @param logGroupName Log group name
|
|
26
|
-
* @param logStreamName Log stream name
|
|
27
|
-
* @param createdStreams Set to track already created streams
|
|
28
|
-
*/
|
|
29
|
-
async function ensureLogStreamExists(client, logGroupName, logStreamName, createdStreams) {
|
|
30
|
-
const streamKey = `${logGroupName}/${logStreamName}`;
|
|
31
|
-
if (createdStreams.has(streamKey)) return;
|
|
32
|
-
try {
|
|
33
|
-
const command = new __aws_sdk_client_cloudwatch_logs.CreateLogStreamCommand({
|
|
34
|
-
logGroupName,
|
|
35
|
-
logStreamName
|
|
36
|
-
});
|
|
37
|
-
await client.send(command);
|
|
38
|
-
createdStreams.add(streamKey);
|
|
39
|
-
} catch (error) {
|
|
40
|
-
if (error instanceof __aws_sdk_client_cloudwatch_logs.ResourceAlreadyExistsException) createdStreams.add(streamKey);
|
|
41
|
-
else {
|
|
42
|
-
const metaLogger = (0, __logtape_logtape.getLogger)([
|
|
43
|
-
"logtape",
|
|
44
|
-
"meta",
|
|
45
|
-
"cloudwatch-logs"
|
|
46
|
-
]);
|
|
47
|
-
metaLogger.error("Failed to create log stream {logStreamName} in group {logGroupName}: {error}", {
|
|
48
|
-
logStreamName,
|
|
49
|
-
logGroupName,
|
|
50
|
-
error
|
|
51
|
-
});
|
|
52
|
-
throw error;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
10
|
* Gets a CloudWatch Logs sink that sends log records to AWS CloudWatch Logs.
|
|
58
11
|
*
|
|
59
12
|
* @param options Configuration options for the CloudWatch Logs sink.
|
|
@@ -69,8 +22,6 @@ function getCloudWatchLogsSink(options) {
|
|
|
69
22
|
const flushInterval = options.flushInterval ?? 1e3;
|
|
70
23
|
const maxRetries = Math.max(options.maxRetries ?? 3, 0);
|
|
71
24
|
const retryDelay = Math.max(options.retryDelay ?? 100, 0);
|
|
72
|
-
const logStreamName = options.autoCreateLogStream && "logStreamNameTemplate" in options ? resolveLogStreamName(options.logStreamNameTemplate) : options.logStreamName;
|
|
73
|
-
const createdStreams = /* @__PURE__ */ new Set();
|
|
74
25
|
const defaultFormatter = (record) => {
|
|
75
26
|
let result = "";
|
|
76
27
|
for (let i = 0; i < record.message.length; i++) if (i % 2 === 0) result += record.message[i];
|
|
@@ -82,7 +33,6 @@ function getCloudWatchLogsSink(options) {
|
|
|
82
33
|
let currentBatchSize = 0;
|
|
83
34
|
let flushTimer = null;
|
|
84
35
|
let disposed = false;
|
|
85
|
-
let flushPromise = null;
|
|
86
36
|
function scheduleFlush() {
|
|
87
37
|
if (flushInterval <= 0 || flushTimer !== null) return;
|
|
88
38
|
flushTimer = setTimeout(() => {
|
|
@@ -91,16 +41,6 @@ function getCloudWatchLogsSink(options) {
|
|
|
91
41
|
}, flushInterval);
|
|
92
42
|
}
|
|
93
43
|
async function flushEvents() {
|
|
94
|
-
if (logEvents.length === 0 || disposed) return;
|
|
95
|
-
if (flushPromise !== null) {
|
|
96
|
-
await flushPromise;
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
flushPromise = doFlush();
|
|
100
|
-
await flushPromise;
|
|
101
|
-
flushPromise = null;
|
|
102
|
-
}
|
|
103
|
-
async function doFlush() {
|
|
104
44
|
if (logEvents.length === 0 || disposed) return;
|
|
105
45
|
const events = logEvents.splice(0);
|
|
106
46
|
currentBatchSize = 0;
|
|
@@ -108,14 +48,13 @@ function getCloudWatchLogsSink(options) {
|
|
|
108
48
|
clearTimeout(flushTimer);
|
|
109
49
|
flushTimer = null;
|
|
110
50
|
}
|
|
111
|
-
if (options.autoCreateLogStream) await ensureLogStreamExists(client, options.logGroupName, logStreamName, createdStreams);
|
|
112
51
|
await sendEventsWithRetry(events, maxRetries);
|
|
113
52
|
}
|
|
114
53
|
async function sendEventsWithRetry(events, remainingRetries) {
|
|
115
54
|
try {
|
|
116
55
|
const command = new __aws_sdk_client_cloudwatch_logs.PutLogEventsCommand({
|
|
117
56
|
logGroupName: options.logGroupName,
|
|
118
|
-
logStreamName,
|
|
57
|
+
logStreamName: options.logStreamName,
|
|
119
58
|
logEvents: events
|
|
120
59
|
});
|
|
121
60
|
await client.send(command);
|
package/dist/sink.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sink.d.cts","names":[],"sources":["../sink.ts"],"sourcesContent":[],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"sink.d.cts","names":[],"sources":["../sink.ts"],"sourcesContent":[],"mappings":";;;;;;;AA0BA;;;;;AAEyB,iBAFT,qBAAA,CAES,OAAA,EADd,yBACc,CAAA,EAAtB,IAAsB,GAAf,eAAe"}
|
package/dist/sink.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sink.d.ts","names":[],"sources":["../sink.ts"],"sourcesContent":[],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"sink.d.ts","names":[],"sources":["../sink.ts"],"sourcesContent":[],"mappings":";;;;;;;AA0BA;;;;;AAEyB,iBAFT,qBAAA,CAES,OAAA,EADd,yBACc,CAAA,EAAtB,IAAsB,GAAf,eAAe"}
|
package/dist/sink.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CloudWatchLogsClient,
|
|
1
|
+
import { CloudWatchLogsClient, PutLogEventsCommand } from "@aws-sdk/client-cloudwatch-logs";
|
|
2
2
|
import { getLogger } from "@logtape/logtape";
|
|
3
3
|
|
|
4
4
|
//#region sink.ts
|
|
@@ -6,53 +6,6 @@ const MAX_BATCH_SIZE_EVENTS = 1e4;
|
|
|
6
6
|
const MAX_BATCH_SIZE_BYTES = 1048576;
|
|
7
7
|
const OVERHEAD_PER_EVENT = 26;
|
|
8
8
|
/**
|
|
9
|
-
* Resolves the log stream name from template.
|
|
10
|
-
* @param logStreamNameTemplate Template for generating stream names
|
|
11
|
-
* @returns Resolved log stream name
|
|
12
|
-
*/
|
|
13
|
-
function resolveLogStreamName(logStreamNameTemplate) {
|
|
14
|
-
const now = /* @__PURE__ */ new Date();
|
|
15
|
-
const year = now.getFullYear().toString();
|
|
16
|
-
const month = (now.getMonth() + 1).toString().padStart(2, "0");
|
|
17
|
-
const day = now.getDate().toString().padStart(2, "0");
|
|
18
|
-
const timestamp = now.getTime().toString();
|
|
19
|
-
return logStreamNameTemplate.replace(/\{YYYY\}/g, year).replace(/\{MM\}/g, month).replace(/\{DD\}/g, day).replace(/\{YYYY-MM-DD\}/g, `${year}-${month}-${day}`).replace(/\{timestamp\}/g, timestamp);
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Ensures that the log stream exists, creating it if necessary.
|
|
23
|
-
* @param client CloudWatch Logs client
|
|
24
|
-
* @param logGroupName Log group name
|
|
25
|
-
* @param logStreamName Log stream name
|
|
26
|
-
* @param createdStreams Set to track already created streams
|
|
27
|
-
*/
|
|
28
|
-
async function ensureLogStreamExists(client, logGroupName, logStreamName, createdStreams) {
|
|
29
|
-
const streamKey = `${logGroupName}/${logStreamName}`;
|
|
30
|
-
if (createdStreams.has(streamKey)) return;
|
|
31
|
-
try {
|
|
32
|
-
const command = new CreateLogStreamCommand({
|
|
33
|
-
logGroupName,
|
|
34
|
-
logStreamName
|
|
35
|
-
});
|
|
36
|
-
await client.send(command);
|
|
37
|
-
createdStreams.add(streamKey);
|
|
38
|
-
} catch (error) {
|
|
39
|
-
if (error instanceof ResourceAlreadyExistsException) createdStreams.add(streamKey);
|
|
40
|
-
else {
|
|
41
|
-
const metaLogger = getLogger([
|
|
42
|
-
"logtape",
|
|
43
|
-
"meta",
|
|
44
|
-
"cloudwatch-logs"
|
|
45
|
-
]);
|
|
46
|
-
metaLogger.error("Failed to create log stream {logStreamName} in group {logGroupName}: {error}", {
|
|
47
|
-
logStreamName,
|
|
48
|
-
logGroupName,
|
|
49
|
-
error
|
|
50
|
-
});
|
|
51
|
-
throw error;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
9
|
* Gets a CloudWatch Logs sink that sends log records to AWS CloudWatch Logs.
|
|
57
10
|
*
|
|
58
11
|
* @param options Configuration options for the CloudWatch Logs sink.
|
|
@@ -68,8 +21,6 @@ function getCloudWatchLogsSink(options) {
|
|
|
68
21
|
const flushInterval = options.flushInterval ?? 1e3;
|
|
69
22
|
const maxRetries = Math.max(options.maxRetries ?? 3, 0);
|
|
70
23
|
const retryDelay = Math.max(options.retryDelay ?? 100, 0);
|
|
71
|
-
const logStreamName = options.autoCreateLogStream && "logStreamNameTemplate" in options ? resolveLogStreamName(options.logStreamNameTemplate) : options.logStreamName;
|
|
72
|
-
const createdStreams = /* @__PURE__ */ new Set();
|
|
73
24
|
const defaultFormatter = (record) => {
|
|
74
25
|
let result = "";
|
|
75
26
|
for (let i = 0; i < record.message.length; i++) if (i % 2 === 0) result += record.message[i];
|
|
@@ -81,7 +32,6 @@ function getCloudWatchLogsSink(options) {
|
|
|
81
32
|
let currentBatchSize = 0;
|
|
82
33
|
let flushTimer = null;
|
|
83
34
|
let disposed = false;
|
|
84
|
-
let flushPromise = null;
|
|
85
35
|
function scheduleFlush() {
|
|
86
36
|
if (flushInterval <= 0 || flushTimer !== null) return;
|
|
87
37
|
flushTimer = setTimeout(() => {
|
|
@@ -90,16 +40,6 @@ function getCloudWatchLogsSink(options) {
|
|
|
90
40
|
}, flushInterval);
|
|
91
41
|
}
|
|
92
42
|
async function flushEvents() {
|
|
93
|
-
if (logEvents.length === 0 || disposed) return;
|
|
94
|
-
if (flushPromise !== null) {
|
|
95
|
-
await flushPromise;
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
flushPromise = doFlush();
|
|
99
|
-
await flushPromise;
|
|
100
|
-
flushPromise = null;
|
|
101
|
-
}
|
|
102
|
-
async function doFlush() {
|
|
103
43
|
if (logEvents.length === 0 || disposed) return;
|
|
104
44
|
const events = logEvents.splice(0);
|
|
105
45
|
currentBatchSize = 0;
|
|
@@ -107,14 +47,13 @@ function getCloudWatchLogsSink(options) {
|
|
|
107
47
|
clearTimeout(flushTimer);
|
|
108
48
|
flushTimer = null;
|
|
109
49
|
}
|
|
110
|
-
if (options.autoCreateLogStream) await ensureLogStreamExists(client, options.logGroupName, logStreamName, createdStreams);
|
|
111
50
|
await sendEventsWithRetry(events, maxRetries);
|
|
112
51
|
}
|
|
113
52
|
async function sendEventsWithRetry(events, remainingRetries) {
|
|
114
53
|
try {
|
|
115
54
|
const command = new PutLogEventsCommand({
|
|
116
55
|
logGroupName: options.logGroupName,
|
|
117
|
-
logStreamName,
|
|
56
|
+
logStreamName: options.logStreamName,
|
|
118
57
|
logEvents: events
|
|
119
58
|
});
|
|
120
59
|
await client.send(command);
|
package/dist/sink.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sink.js","names":["logStreamNameTemplate: string","client: CloudWatchLogsClient","logGroupName: string","logStreamName: string","createdStreams: Set<string>","options: CloudWatchLogsSinkOptions","defaultFormatter: TextFormatter","logEvents: InputLogEvent[]","flushTimer: ReturnType<typeof setTimeout> | null","flushPromise: Promise<void> | null","events: InputLogEvent[]","remainingRetries: number","record: LogRecord","sink: Sink & AsyncDisposable","logEvent: InputLogEvent"],"sources":["../sink.ts"],"sourcesContent":["import {\n CloudWatchLogsClient,\n CreateLogStreamCommand,\n type InputLogEvent,\n PutLogEventsCommand,\n ResourceAlreadyExistsException,\n} from \"@aws-sdk/client-cloudwatch-logs\";\nimport {\n getLogger,\n type LogRecord,\n type Sink,\n type TextFormatter,\n} from \"@logtape/logtape\";\nimport type { CloudWatchLogsSinkOptions } from \"./types.ts\";\n\n// AWS CloudWatch Logs PutLogEvents API limits\n// See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/cloudwatch_limits_cwl.html\nconst MAX_BATCH_SIZE_EVENTS = 10000; // Maximum 10,000 events per batch\nconst MAX_BATCH_SIZE_BYTES = 1048576; // Maximum batch size: 1 MiB (1,048,576 bytes)\nconst OVERHEAD_PER_EVENT = 26; // AWS overhead per log event: 26 bytes per event\n\n/**\n * Resolves the log stream name from template.\n * @param logStreamNameTemplate Template for generating stream names\n * @returns Resolved log stream name\n */\nfunction resolveLogStreamName(\n logStreamNameTemplate: string,\n): string {\n const now = new Date();\n const year = now.getFullYear().toString();\n const month = (now.getMonth() + 1).toString().padStart(2, \"0\");\n const day = now.getDate().toString().padStart(2, \"0\");\n const timestamp = now.getTime().toString();\n\n return logStreamNameTemplate\n .replace(/\\{YYYY\\}/g, year)\n .replace(/\\{MM\\}/g, month)\n .replace(/\\{DD\\}/g, day)\n .replace(/\\{YYYY-MM-DD\\}/g, `${year}-${month}-${day}`)\n .replace(/\\{timestamp\\}/g, timestamp);\n}\n\n/**\n * Ensures that the log stream exists, creating it if necessary.\n * @param client CloudWatch Logs client\n * @param logGroupName Log group name\n * @param logStreamName Log stream name\n * @param createdStreams Set to track already created streams\n */\nasync function ensureLogStreamExists(\n client: CloudWatchLogsClient,\n logGroupName: string,\n logStreamName: string,\n createdStreams: Set<string>,\n): Promise<void> {\n const streamKey = `${logGroupName}/${logStreamName}`;\n\n // If we've already created this stream, skip\n if (createdStreams.has(streamKey)) {\n return;\n }\n\n try {\n const command = new CreateLogStreamCommand({\n logGroupName,\n logStreamName,\n });\n\n await client.send(command);\n createdStreams.add(streamKey);\n } catch (error) {\n if (error instanceof ResourceAlreadyExistsException) {\n // Stream already exists, this is fine\n createdStreams.add(streamKey);\n } else {\n // Log stream creation failure to meta logger\n const metaLogger = getLogger([\"logtape\", \"meta\", \"cloudwatch-logs\"]);\n metaLogger.error(\n \"Failed to create log stream {logStreamName} in group {logGroupName}: {error}\",\n { logStreamName, logGroupName, error },\n );\n // Re-throw other errors\n throw error;\n }\n }\n}\n\n/**\n * Gets a CloudWatch Logs sink that sends log records to AWS CloudWatch Logs.\n *\n * @param options Configuration options for the CloudWatch Logs sink.\n * @returns A sink that sends log records to CloudWatch Logs.\n * @since 1.0.0\n */\nexport function getCloudWatchLogsSink(\n options: CloudWatchLogsSinkOptions,\n): Sink & AsyncDisposable {\n const client = options.client ??\n new CloudWatchLogsClient({\n region: options.region ?? \"us-east-1\",\n credentials: options.credentials,\n });\n\n const batchSize = Math.min(\n Math.max(options.batchSize ?? 1000, 1),\n MAX_BATCH_SIZE_EVENTS,\n );\n const flushInterval = options.flushInterval ?? 1000;\n const maxRetries = Math.max(options.maxRetries ?? 3, 0);\n const retryDelay = Math.max(options.retryDelay ?? 100, 0);\n\n // Resolve the log stream name\n const logStreamName =\n options.autoCreateLogStream && \"logStreamNameTemplate\" in options\n ? resolveLogStreamName(options.logStreamNameTemplate)\n : options.logStreamName;\n\n // Track created streams to avoid redundant API calls\n const createdStreams = new Set<string>();\n\n // Default formatter that formats message parts into a simple string\n const defaultFormatter: TextFormatter = (record) => {\n let result = \"\";\n for (let i = 0; i < record.message.length; i++) {\n if (i % 2 === 0) {\n result += record.message[i];\n } else {\n result += JSON.stringify(record.message[i]);\n }\n }\n return result;\n };\n\n const formatter = options.formatter ?? defaultFormatter;\n\n const logEvents: InputLogEvent[] = [];\n let currentBatchSize = 0;\n let flushTimer: ReturnType<typeof setTimeout> | null = null;\n let disposed = false;\n let flushPromise: Promise<void> | null = null;\n\n function scheduleFlush(): void {\n if (flushInterval <= 0 || flushTimer !== null) return;\n\n flushTimer = setTimeout(() => {\n flushTimer = null;\n if (logEvents.length > 0) {\n void flushEvents();\n }\n }, flushInterval);\n }\n\n async function flushEvents(): Promise<void> {\n if (logEvents.length === 0 || disposed) return;\n\n // If there's already a flush in progress, wait for it\n if (flushPromise !== null) {\n await flushPromise;\n return;\n }\n\n // Start a new flush operation\n flushPromise = doFlush();\n await flushPromise;\n flushPromise = null;\n }\n\n async function doFlush(): Promise<void> {\n if (logEvents.length === 0 || disposed) return;\n\n const events = logEvents.splice(0);\n currentBatchSize = 0;\n\n if (flushTimer !== null) {\n clearTimeout(flushTimer);\n flushTimer = null;\n }\n\n // Auto-create log stream if enabled (only once per stream)\n if (options.autoCreateLogStream) {\n await ensureLogStreamExists(\n client,\n options.logGroupName,\n logStreamName,\n createdStreams,\n );\n }\n\n await sendEventsWithRetry(events, maxRetries);\n }\n\n async function sendEventsWithRetry(\n events: InputLogEvent[],\n remainingRetries: number,\n ): Promise<void> {\n try {\n const command = new PutLogEventsCommand({\n logGroupName: options.logGroupName,\n logStreamName: logStreamName,\n logEvents: events,\n });\n\n await client.send(command);\n } catch (error) {\n if (remainingRetries > 0) {\n // Calculate exponential backoff: base, base*2, base*4, etc.\n const attemptNumber = maxRetries - remainingRetries;\n const delay = retryDelay * Math.pow(2, attemptNumber);\n await new Promise((resolve) => setTimeout(resolve, delay));\n await sendEventsWithRetry(events, remainingRetries - 1);\n } else {\n // Log to meta logger to avoid crashing the application\n const metaLogger = getLogger([\"logtape\", \"meta\", \"cloudwatch-logs\"]);\n metaLogger.error(\n \"Failed to send log events to CloudWatch Logs after {maxRetries} retries: {error}\",\n { maxRetries, error },\n );\n }\n }\n }\n\n function formatLogMessage(record: LogRecord): string {\n return formatter(record);\n }\n\n const sink: Sink & AsyncDisposable = (record: LogRecord) => {\n if (disposed) return;\n\n // Skip meta logger logs to prevent infinite loops\n if (\n record.category[0] === \"logtape\" &&\n record.category[1] === \"meta\" &&\n record.category[2] === \"cloudwatch-logs\"\n ) {\n return;\n }\n\n const message = formatLogMessage(record);\n const messageBytes = new TextEncoder().encode(message).length;\n const eventSize = messageBytes + OVERHEAD_PER_EVENT;\n\n const logEvent: InputLogEvent = {\n timestamp: record.timestamp,\n message,\n };\n\n logEvents.push(logEvent);\n currentBatchSize += eventSize;\n\n const shouldFlushBySize = currentBatchSize > MAX_BATCH_SIZE_BYTES;\n const shouldFlushByCount = logEvents.length >= batchSize;\n\n if (shouldFlushBySize || shouldFlushByCount) {\n void flushEvents();\n } else {\n scheduleFlush();\n }\n };\n\n sink[Symbol.asyncDispose] = async () => {\n if (flushTimer !== null) {\n clearTimeout(flushTimer);\n flushTimer = null;\n }\n await flushEvents();\n disposed = true;\n };\n\n return sink;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sink.js","names":["options: CloudWatchLogsSinkOptions","defaultFormatter: TextFormatter","logEvents: InputLogEvent[]","flushTimer: ReturnType<typeof setTimeout> | null","events: InputLogEvent[]","remainingRetries: number","record: LogRecord","sink: Sink & AsyncDisposable","logEvent: InputLogEvent"],"sources":["../sink.ts"],"sourcesContent":["import {\n CloudWatchLogsClient,\n type InputLogEvent,\n PutLogEventsCommand,\n} from \"@aws-sdk/client-cloudwatch-logs\";\nimport {\n getLogger,\n type LogRecord,\n type Sink,\n type TextFormatter,\n} from \"@logtape/logtape\";\nimport type { CloudWatchLogsSinkOptions } from \"./types.ts\";\n\n// AWS CloudWatch Logs PutLogEvents API limits\n// See: https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/cloudwatch_limits_cwl.html\nconst MAX_BATCH_SIZE_EVENTS = 10000; // Maximum 10,000 events per batch\nconst MAX_BATCH_SIZE_BYTES = 1048576; // Maximum batch size: 1 MiB (1,048,576 bytes)\nconst OVERHEAD_PER_EVENT = 26; // AWS overhead per log event: 26 bytes per event\n\n/**\n * Gets a CloudWatch Logs sink that sends log records to AWS CloudWatch Logs.\n *\n * @param options Configuration options for the CloudWatch Logs sink.\n * @returns A sink that sends log records to CloudWatch Logs.\n * @since 1.0.0\n */\nexport function getCloudWatchLogsSink(\n options: CloudWatchLogsSinkOptions,\n): Sink & AsyncDisposable {\n const client = options.client ??\n new CloudWatchLogsClient({\n region: options.region ?? \"us-east-1\",\n credentials: options.credentials,\n });\n\n const batchSize = Math.min(\n Math.max(options.batchSize ?? 1000, 1),\n MAX_BATCH_SIZE_EVENTS,\n );\n const flushInterval = options.flushInterval ?? 1000;\n const maxRetries = Math.max(options.maxRetries ?? 3, 0);\n const retryDelay = Math.max(options.retryDelay ?? 100, 0);\n\n // Default formatter that formats message parts into a simple string\n const defaultFormatter: TextFormatter = (record) => {\n let result = \"\";\n for (let i = 0; i < record.message.length; i++) {\n if (i % 2 === 0) {\n result += record.message[i];\n } else {\n result += JSON.stringify(record.message[i]);\n }\n }\n return result;\n };\n\n const formatter = options.formatter ?? defaultFormatter;\n\n const logEvents: InputLogEvent[] = [];\n let currentBatchSize = 0;\n let flushTimer: ReturnType<typeof setTimeout> | null = null;\n let disposed = false;\n\n function scheduleFlush(): void {\n if (flushInterval <= 0 || flushTimer !== null) return;\n\n flushTimer = setTimeout(() => {\n flushTimer = null;\n if (logEvents.length > 0) {\n void flushEvents();\n }\n }, flushInterval);\n }\n\n async function flushEvents(): Promise<void> {\n if (logEvents.length === 0 || disposed) return;\n\n const events = logEvents.splice(0);\n currentBatchSize = 0;\n\n if (flushTimer !== null) {\n clearTimeout(flushTimer);\n flushTimer = null;\n }\n\n await sendEventsWithRetry(events, maxRetries);\n }\n\n async function sendEventsWithRetry(\n events: InputLogEvent[],\n remainingRetries: number,\n ): Promise<void> {\n try {\n const command = new PutLogEventsCommand({\n logGroupName: options.logGroupName,\n logStreamName: options.logStreamName,\n logEvents: events,\n });\n\n await client.send(command);\n } catch (error) {\n if (remainingRetries > 0) {\n // Calculate exponential backoff: base, base*2, base*4, etc.\n const attemptNumber = maxRetries - remainingRetries;\n const delay = retryDelay * Math.pow(2, attemptNumber);\n await new Promise((resolve) => setTimeout(resolve, delay));\n await sendEventsWithRetry(events, remainingRetries - 1);\n } else {\n // Log to meta logger to avoid crashing the application\n const metaLogger = getLogger([\"logtape\", \"meta\", \"cloudwatch-logs\"]);\n metaLogger.error(\n \"Failed to send log events to CloudWatch Logs after {maxRetries} retries: {error}\",\n { maxRetries, error },\n );\n }\n }\n }\n\n function formatLogMessage(record: LogRecord): string {\n return formatter(record);\n }\n\n const sink: Sink & AsyncDisposable = (record: LogRecord) => {\n if (disposed) return;\n\n // Skip meta logger logs to prevent infinite loops\n if (\n record.category[0] === \"logtape\" &&\n record.category[1] === \"meta\" &&\n record.category[2] === \"cloudwatch-logs\"\n ) {\n return;\n }\n\n const message = formatLogMessage(record);\n const messageBytes = new TextEncoder().encode(message).length;\n const eventSize = messageBytes + OVERHEAD_PER_EVENT;\n\n const logEvent: InputLogEvent = {\n timestamp: record.timestamp,\n message,\n };\n\n logEvents.push(logEvent);\n currentBatchSize += eventSize;\n\n const shouldFlushBySize = currentBatchSize > MAX_BATCH_SIZE_BYTES;\n const shouldFlushByCount = logEvents.length >= batchSize;\n\n if (shouldFlushBySize || shouldFlushByCount) {\n void flushEvents();\n } else {\n scheduleFlush();\n }\n };\n\n sink[Symbol.asyncDispose] = async () => {\n if (flushTimer !== null) {\n clearTimeout(flushTimer);\n flushTimer = null;\n }\n await flushEvents();\n disposed = true;\n };\n\n return sink;\n}\n"],"mappings":";;;;AAeA,MAAM,wBAAwB;AAC9B,MAAM,uBAAuB;AAC7B,MAAM,qBAAqB;;;;;;;;AAS3B,SAAgB,sBACdA,SACwB;CACxB,MAAM,SAAS,QAAQ,UACrB,IAAI,qBAAqB;EACvB,QAAQ,QAAQ,UAAU;EAC1B,aAAa,QAAQ;CACtB;CAEH,MAAM,YAAY,KAAK,IACrB,KAAK,IAAI,QAAQ,aAAa,KAAM,EAAE,EACtC,sBACD;CACD,MAAM,gBAAgB,QAAQ,iBAAiB;CAC/C,MAAM,aAAa,KAAK,IAAI,QAAQ,cAAc,GAAG,EAAE;CACvD,MAAM,aAAa,KAAK,IAAI,QAAQ,cAAc,KAAK,EAAE;CAGzD,MAAMC,mBAAkC,CAAC,WAAW;EAClD,IAAI,SAAS;AACb,OAAK,IAAI,IAAI,GAAG,IAAI,OAAO,QAAQ,QAAQ,IACzC,KAAI,IAAI,MAAM,EACZ,WAAU,OAAO,QAAQ;MAEzB,WAAU,KAAK,UAAU,OAAO,QAAQ,GAAG;AAG/C,SAAO;CACR;CAED,MAAM,YAAY,QAAQ,aAAa;CAEvC,MAAMC,YAA6B,CAAE;CACrC,IAAI,mBAAmB;CACvB,IAAIC,aAAmD;CACvD,IAAI,WAAW;CAEf,SAAS,gBAAsB;AAC7B,MAAI,iBAAiB,KAAK,eAAe,KAAM;AAE/C,eAAa,WAAW,MAAM;AAC5B,gBAAa;AACb,OAAI,UAAU,SAAS,EACrB,CAAK,aAAa;EAErB,GAAE,cAAc;CAClB;CAED,eAAe,cAA6B;AAC1C,MAAI,UAAU,WAAW,KAAK,SAAU;EAExC,MAAM,SAAS,UAAU,OAAO,EAAE;AAClC,qBAAmB;AAEnB,MAAI,eAAe,MAAM;AACvB,gBAAa,WAAW;AACxB,gBAAa;EACd;AAED,QAAM,oBAAoB,QAAQ,WAAW;CAC9C;CAED,eAAe,oBACbC,QACAC,kBACe;AACf,MAAI;GACF,MAAM,UAAU,IAAI,oBAAoB;IACtC,cAAc,QAAQ;IACtB,eAAe,QAAQ;IACvB,WAAW;GACZ;AAED,SAAM,OAAO,KAAK,QAAQ;EAC3B,SAAQ,OAAO;AACd,OAAI,mBAAmB,GAAG;IAExB,MAAM,gBAAgB,aAAa;IACnC,MAAM,QAAQ,aAAa,KAAK,IAAI,GAAG,cAAc;AACrD,UAAM,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,MAAM;AACzD,UAAM,oBAAoB,QAAQ,mBAAmB,EAAE;GACxD,OAAM;IAEL,MAAM,aAAa,UAAU;KAAC;KAAW;KAAQ;IAAkB,EAAC;AACpE,eAAW,MACT,oFACA;KAAE;KAAY;IAAO,EACtB;GACF;EACF;CACF;CAED,SAAS,iBAAiBC,QAA2B;AACnD,SAAO,UAAU,OAAO;CACzB;CAED,MAAMC,OAA+B,CAACD,WAAsB;AAC1D,MAAI,SAAU;AAGd,MACE,OAAO,SAAS,OAAO,aACvB,OAAO,SAAS,OAAO,UACvB,OAAO,SAAS,OAAO,kBAEvB;EAGF,MAAM,UAAU,iBAAiB,OAAO;EACxC,MAAM,eAAe,IAAI,cAAc,OAAO,QAAQ,CAAC;EACvD,MAAM,YAAY,eAAe;EAEjC,MAAME,WAA0B;GAC9B,WAAW,OAAO;GAClB;EACD;AAED,YAAU,KAAK,SAAS;AACxB,sBAAoB;EAEpB,MAAM,oBAAoB,mBAAmB;EAC7C,MAAM,qBAAqB,UAAU,UAAU;AAE/C,MAAI,qBAAqB,mBACvB,CAAK,aAAa;MAElB,gBAAe;CAElB;AAED,MAAK,OAAO,gBAAgB,YAAY;AACtC,MAAI,eAAe,MAAM;AACvB,gBAAa,WAAW;AACxB,gBAAa;EACd;AACD,QAAM,aAAa;AACnB,aAAW;CACZ;AAED,QAAO;AACR"}
|
package/dist/types.d.cts
CHANGED
|
@@ -7,7 +7,7 @@ import { TextFormatter } from "@logtape/logtape";
|
|
|
7
7
|
* Options for configuring the CloudWatch Logs sink.
|
|
8
8
|
* @since 1.0.0
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
interface CloudWatchLogsSinkOptions {
|
|
11
11
|
/**
|
|
12
12
|
* An existing CloudWatch Logs client instance.
|
|
13
13
|
* If provided, the client will be used directly and other connection
|
|
@@ -18,6 +18,10 @@ type CloudWatchLogsSinkOptions = {
|
|
|
18
18
|
* The name of the log group to send log events to.
|
|
19
19
|
*/
|
|
20
20
|
readonly logGroupName: string;
|
|
21
|
+
/**
|
|
22
|
+
* The name of the log stream within the log group.
|
|
23
|
+
*/
|
|
24
|
+
readonly logStreamName: string;
|
|
21
25
|
/**
|
|
22
26
|
* The AWS region to use when creating a new client.
|
|
23
27
|
* Ignored if `client` is provided.
|
|
@@ -64,53 +68,7 @@ type CloudWatchLogsSinkOptions = {
|
|
|
64
68
|
* @since 1.0.0
|
|
65
69
|
*/
|
|
66
70
|
readonly formatter?: TextFormatter;
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Whether to automatically create the log stream if it doesn't exist.
|
|
70
|
-
* When enabled, the sink will attempt to create the specified log stream
|
|
71
|
-
* before sending log events. If the stream already exists, the creation
|
|
72
|
-
* attempt will be safely ignored.
|
|
73
|
-
* @default false
|
|
74
|
-
* @since 1.1.0
|
|
75
|
-
*/
|
|
76
|
-
readonly autoCreateLogStream?: false;
|
|
77
|
-
/**
|
|
78
|
-
* The name of the log stream within the log group.
|
|
79
|
-
* Required unless `logStreamNameTemplate` is provided.
|
|
80
|
-
*/
|
|
81
|
-
readonly logStreamName: string;
|
|
82
|
-
} | {
|
|
83
|
-
/**
|
|
84
|
-
* Whether to automatically create the log stream if it doesn't exist.
|
|
85
|
-
* When enabled, the sink will attempt to create the specified log stream
|
|
86
|
-
* before sending log events. If the stream already exists, the creation
|
|
87
|
-
* attempt will be safely ignored.
|
|
88
|
-
* @default false
|
|
89
|
-
* @since 1.1.0
|
|
90
|
-
*/
|
|
91
|
-
readonly autoCreateLogStream: true;
|
|
92
|
-
} & ({
|
|
93
|
-
/**
|
|
94
|
-
* The name of the log stream within the log group.
|
|
95
|
-
* Required unless `logStreamNameTemplate` is provided.
|
|
96
|
-
*/
|
|
97
|
-
readonly logStreamName: string;
|
|
98
|
-
} | {
|
|
99
|
-
/**
|
|
100
|
-
* Template for generating dynamic log stream names.
|
|
101
|
-
* Supports the following placeholders:
|
|
102
|
-
* - `{YYYY}`: 4-digit year
|
|
103
|
-
* - `{MM}`: 2-digit month (01-12)
|
|
104
|
-
* - `{DD}`: 2-digit day (01-31)
|
|
105
|
-
* - `{YYYY-MM-DD}`: Date in YYYY-MM-DD format
|
|
106
|
-
* - `{timestamp}`: Unix timestamp in milliseconds
|
|
107
|
-
*
|
|
108
|
-
* If provided, this will be used instead of `logStreamName`.
|
|
109
|
-
* Only used when `autoCreateLogStream` is true.
|
|
110
|
-
* @since 1.1.0
|
|
111
|
-
*/
|
|
112
|
-
readonly logStreamNameTemplate: string;
|
|
113
|
-
}));
|
|
71
|
+
}
|
|
114
72
|
//# sourceMappingURL=types.d.ts.map
|
|
115
73
|
//#endregion
|
|
116
74
|
export { CloudWatchLogsSinkOptions };
|
package/dist/types.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.cts","names":[],"sources":["../types.ts"],"sourcesContent":[],"mappings":";;;;;;;AAOA;;
|
|
1
|
+
{"version":3,"file":"types.d.cts","names":[],"sources":["../types.ts"],"sourcesContent":[],"mappings":";;;;;;;AAOA;;AAMoB,UANH,yBAAA,CAMG;EAAoB;AA+DJ;;;;oBA/DhB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBA+DG"}
|
package/dist/types.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { TextFormatter } from "@logtape/logtape";
|
|
|
7
7
|
* Options for configuring the CloudWatch Logs sink.
|
|
8
8
|
* @since 1.0.0
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
interface CloudWatchLogsSinkOptions {
|
|
11
11
|
/**
|
|
12
12
|
* An existing CloudWatch Logs client instance.
|
|
13
13
|
* If provided, the client will be used directly and other connection
|
|
@@ -18,6 +18,10 @@ type CloudWatchLogsSinkOptions = {
|
|
|
18
18
|
* The name of the log group to send log events to.
|
|
19
19
|
*/
|
|
20
20
|
readonly logGroupName: string;
|
|
21
|
+
/**
|
|
22
|
+
* The name of the log stream within the log group.
|
|
23
|
+
*/
|
|
24
|
+
readonly logStreamName: string;
|
|
21
25
|
/**
|
|
22
26
|
* The AWS region to use when creating a new client.
|
|
23
27
|
* Ignored if `client` is provided.
|
|
@@ -64,53 +68,7 @@ type CloudWatchLogsSinkOptions = {
|
|
|
64
68
|
* @since 1.0.0
|
|
65
69
|
*/
|
|
66
70
|
readonly formatter?: TextFormatter;
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Whether to automatically create the log stream if it doesn't exist.
|
|
70
|
-
* When enabled, the sink will attempt to create the specified log stream
|
|
71
|
-
* before sending log events. If the stream already exists, the creation
|
|
72
|
-
* attempt will be safely ignored.
|
|
73
|
-
* @default false
|
|
74
|
-
* @since 1.1.0
|
|
75
|
-
*/
|
|
76
|
-
readonly autoCreateLogStream?: false;
|
|
77
|
-
/**
|
|
78
|
-
* The name of the log stream within the log group.
|
|
79
|
-
* Required unless `logStreamNameTemplate` is provided.
|
|
80
|
-
*/
|
|
81
|
-
readonly logStreamName: string;
|
|
82
|
-
} | {
|
|
83
|
-
/**
|
|
84
|
-
* Whether to automatically create the log stream if it doesn't exist.
|
|
85
|
-
* When enabled, the sink will attempt to create the specified log stream
|
|
86
|
-
* before sending log events. If the stream already exists, the creation
|
|
87
|
-
* attempt will be safely ignored.
|
|
88
|
-
* @default false
|
|
89
|
-
* @since 1.1.0
|
|
90
|
-
*/
|
|
91
|
-
readonly autoCreateLogStream: true;
|
|
92
|
-
} & ({
|
|
93
|
-
/**
|
|
94
|
-
* The name of the log stream within the log group.
|
|
95
|
-
* Required unless `logStreamNameTemplate` is provided.
|
|
96
|
-
*/
|
|
97
|
-
readonly logStreamName: string;
|
|
98
|
-
} | {
|
|
99
|
-
/**
|
|
100
|
-
* Template for generating dynamic log stream names.
|
|
101
|
-
* Supports the following placeholders:
|
|
102
|
-
* - `{YYYY}`: 4-digit year
|
|
103
|
-
* - `{MM}`: 2-digit month (01-12)
|
|
104
|
-
* - `{DD}`: 2-digit day (01-31)
|
|
105
|
-
* - `{YYYY-MM-DD}`: Date in YYYY-MM-DD format
|
|
106
|
-
* - `{timestamp}`: Unix timestamp in milliseconds
|
|
107
|
-
*
|
|
108
|
-
* If provided, this will be used instead of `logStreamName`.
|
|
109
|
-
* Only used when `autoCreateLogStream` is true.
|
|
110
|
-
* @since 1.1.0
|
|
111
|
-
*/
|
|
112
|
-
readonly logStreamNameTemplate: string;
|
|
113
|
-
}));
|
|
71
|
+
}
|
|
114
72
|
//# sourceMappingURL=types.d.ts.map
|
|
115
73
|
//#endregion
|
|
116
74
|
export { CloudWatchLogsSinkOptions };
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","names":[],"sources":["../types.ts"],"sourcesContent":[],"mappings":";;;;;;;AAOA;;
|
|
1
|
+
{"version":3,"file":"types.d.ts","names":[],"sources":["../types.ts"],"sourcesContent":[],"mappings":";;;;;;;AAOA;;AAMoB,UANH,yBAAA,CAMG;EAAoB;AA+DJ;;;;oBA/DhB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBA+DG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logtape/cloudwatch-logs",
|
|
3
|
-
"version": "1.1.0-dev.
|
|
3
|
+
"version": "1.1.0-dev.315+fc46f65c",
|
|
4
4
|
"description": "AWS CloudWatch Logs sink for LogTape",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"logging",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"sideEffects": false,
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@logtape/logtape": "1.1.0-dev.
|
|
49
|
+
"@logtape/logtape": "1.1.0-dev.315+fc46f65c"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@aws-sdk/client-cloudwatch-logs": "^3.0.0"
|
package/sink.integration.test.ts
CHANGED
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
CreateLogGroupCommand,
|
|
5
5
|
CreateLogStreamCommand,
|
|
6
6
|
DeleteLogGroupCommand,
|
|
7
|
-
DescribeLogStreamsCommand,
|
|
8
7
|
GetLogEventsCommand,
|
|
9
8
|
} from "@aws-sdk/client-cloudwatch-logs";
|
|
10
9
|
import "@dotenvx/dotenvx/config";
|
|
@@ -450,213 +449,3 @@ test("Integration: CloudWatch Logs sink with JSON Lines formatter", async () =>
|
|
|
450
449
|
}
|
|
451
450
|
}
|
|
452
451
|
});
|
|
453
|
-
|
|
454
|
-
test("Integration: CloudWatch Logs sink with auto-create log stream", async () => {
|
|
455
|
-
const autoCreateTestLogGroupName = `/logtape/auto-create-test-${Date.now()}`;
|
|
456
|
-
const autoCreateTestLogStreamName = `auto-create-test-stream-${Date.now()}`;
|
|
457
|
-
|
|
458
|
-
const sink = getCloudWatchLogsSink({
|
|
459
|
-
logGroupName: autoCreateTestLogGroupName,
|
|
460
|
-
logStreamName: autoCreateTestLogStreamName,
|
|
461
|
-
region: process.env.AWS_REGION,
|
|
462
|
-
credentials: {
|
|
463
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
464
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
465
|
-
sessionToken: process.env.AWS_SESSION_TOKEN,
|
|
466
|
-
},
|
|
467
|
-
autoCreateLogStream: true,
|
|
468
|
-
batchSize: 1,
|
|
469
|
-
flushInterval: 0,
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
// Create a separate client for setup/cleanup
|
|
473
|
-
const client = new CloudWatchLogsClient({
|
|
474
|
-
region: process.env.AWS_REGION,
|
|
475
|
-
credentials: {
|
|
476
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
477
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
478
|
-
sessionToken: process.env.AWS_SESSION_TOKEN,
|
|
479
|
-
},
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
try {
|
|
483
|
-
// Only create log group - let sink auto-create the stream
|
|
484
|
-
await client.send(
|
|
485
|
-
new CreateLogGroupCommand({ logGroupName: autoCreateTestLogGroupName }),
|
|
486
|
-
);
|
|
487
|
-
|
|
488
|
-
// Send log record with fixed timestamp
|
|
489
|
-
const fixedTimestamp = 1672531200000; // 2023-01-01T00:00:00.000Z
|
|
490
|
-
const autoCreateTestLogRecord: LogRecord = {
|
|
491
|
-
category: ["auto-create", "test"],
|
|
492
|
-
level: "info",
|
|
493
|
-
message: [
|
|
494
|
-
"Auto-create test message at ",
|
|
495
|
-
new Date(fixedTimestamp).toISOString(),
|
|
496
|
-
],
|
|
497
|
-
rawMessage: "Auto-create test message at {timestamp}",
|
|
498
|
-
timestamp: fixedTimestamp,
|
|
499
|
-
properties: { testId: "auto-create-001" },
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
sink(autoCreateTestLogRecord);
|
|
503
|
-
await sink[Symbol.asyncDispose]();
|
|
504
|
-
|
|
505
|
-
// Wait longer for AWS to process the log event
|
|
506
|
-
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
507
|
-
|
|
508
|
-
// Verify the log event was received by CloudWatch Logs
|
|
509
|
-
const getEventsCommand = new GetLogEventsCommand({
|
|
510
|
-
logGroupName: autoCreateTestLogGroupName,
|
|
511
|
-
logStreamName: autoCreateTestLogStreamName,
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
const response = await client.send(getEventsCommand);
|
|
515
|
-
console.log(
|
|
516
|
-
`Found ${
|
|
517
|
-
response.events?.length ?? 0
|
|
518
|
-
} auto-create events in CloudWatch Logs`,
|
|
519
|
-
);
|
|
520
|
-
if (response.events?.length === 0) {
|
|
521
|
-
console.log(
|
|
522
|
-
"No auto-create events found. This might be due to CloudWatch Logs propagation delay.",
|
|
523
|
-
);
|
|
524
|
-
// Make this test more lenient - just verify the sink worked without errors
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
assertEquals(response.events?.length, 1);
|
|
529
|
-
assertEquals(
|
|
530
|
-
response.events?.[0].message,
|
|
531
|
-
'Auto-create test message at "2023-01-01T00:00:00.000Z"',
|
|
532
|
-
);
|
|
533
|
-
} finally {
|
|
534
|
-
// Always cleanup - delete log group (this also deletes log streams)
|
|
535
|
-
try {
|
|
536
|
-
await client.send(
|
|
537
|
-
new DeleteLogGroupCommand({
|
|
538
|
-
logGroupName: autoCreateTestLogGroupName,
|
|
539
|
-
}),
|
|
540
|
-
);
|
|
541
|
-
} catch (error) {
|
|
542
|
-
console.warn("Failed to cleanup auto-create test log group:", error);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
test("Integration: CloudWatch Logs sink with log stream template", async () => {
|
|
548
|
-
const templateTestLogGroupName = `/logtape/template-test-${Date.now()}`;
|
|
549
|
-
|
|
550
|
-
const sink = getCloudWatchLogsSink({
|
|
551
|
-
logGroupName: templateTestLogGroupName,
|
|
552
|
-
logStreamNameTemplate: "template-{YYYY-MM-DD}-{timestamp}",
|
|
553
|
-
region: process.env.AWS_REGION,
|
|
554
|
-
credentials: {
|
|
555
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
556
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
557
|
-
sessionToken: process.env.AWS_SESSION_TOKEN,
|
|
558
|
-
},
|
|
559
|
-
autoCreateLogStream: true,
|
|
560
|
-
batchSize: 1,
|
|
561
|
-
flushInterval: 0,
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
// Create a separate client for setup/cleanup
|
|
565
|
-
const client = new CloudWatchLogsClient({
|
|
566
|
-
region: process.env.AWS_REGION,
|
|
567
|
-
credentials: {
|
|
568
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
569
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
570
|
-
sessionToken: process.env.AWS_SESSION_TOKEN,
|
|
571
|
-
},
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
try {
|
|
575
|
-
// Only create log group - let sink auto-create the stream with template
|
|
576
|
-
await client.send(
|
|
577
|
-
new CreateLogGroupCommand({ logGroupName: templateTestLogGroupName }),
|
|
578
|
-
);
|
|
579
|
-
|
|
580
|
-
// Send log record with fixed timestamp
|
|
581
|
-
const fixedTimestamp = 1672531200000; // 2023-01-01T00:00:00.000Z
|
|
582
|
-
const templateTestLogRecord: LogRecord = {
|
|
583
|
-
category: ["template", "test"],
|
|
584
|
-
level: "info",
|
|
585
|
-
message: [
|
|
586
|
-
"Template test message at ",
|
|
587
|
-
new Date(fixedTimestamp).toISOString(),
|
|
588
|
-
],
|
|
589
|
-
rawMessage: "Template test message at {timestamp}",
|
|
590
|
-
timestamp: fixedTimestamp,
|
|
591
|
-
properties: { testId: "template-001" },
|
|
592
|
-
};
|
|
593
|
-
|
|
594
|
-
sink(templateTestLogRecord);
|
|
595
|
-
await sink[Symbol.asyncDispose]();
|
|
596
|
-
|
|
597
|
-
// Wait longer for AWS to process the log event
|
|
598
|
-
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
599
|
-
|
|
600
|
-
// Since we don't know the exact generated stream name, list all streams
|
|
601
|
-
const listStreamsCommand = new DescribeLogStreamsCommand({
|
|
602
|
-
logGroupName: templateTestLogGroupName,
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
const streamsResponse = await client.send(listStreamsCommand);
|
|
606
|
-
console.log(
|
|
607
|
-
`Found ${
|
|
608
|
-
streamsResponse.logStreams?.length ?? 0
|
|
609
|
-
} streams in template test log group`,
|
|
610
|
-
);
|
|
611
|
-
|
|
612
|
-
// Find the stream that matches our template pattern
|
|
613
|
-
const templateStream = streamsResponse.logStreams?.find((stream) =>
|
|
614
|
-
stream.logStreamName?.match(/template-\d{4}-\d{2}-\d{2}-\d+/)
|
|
615
|
-
);
|
|
616
|
-
|
|
617
|
-
if (!templateStream) {
|
|
618
|
-
console.log(
|
|
619
|
-
"No template stream found. This might be due to CloudWatch Logs propagation delay.",
|
|
620
|
-
);
|
|
621
|
-
// Make this test more lenient - just verify the sink worked without errors
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Verify the log event was received in the template-generated stream
|
|
626
|
-
const getEventsCommand = new GetLogEventsCommand({
|
|
627
|
-
logGroupName: templateTestLogGroupName,
|
|
628
|
-
logStreamName: templateStream.logStreamName!,
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
const response = await client.send(getEventsCommand);
|
|
632
|
-
console.log(
|
|
633
|
-
`Found ${
|
|
634
|
-
response.events?.length ?? 0
|
|
635
|
-
} template events in stream ${templateStream.logStreamName}`,
|
|
636
|
-
);
|
|
637
|
-
|
|
638
|
-
if (response.events?.length === 0) {
|
|
639
|
-
console.log(
|
|
640
|
-
"No template events found. This might be due to CloudWatch Logs propagation delay.",
|
|
641
|
-
);
|
|
642
|
-
return;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
assertEquals(response.events?.length, 1);
|
|
646
|
-
assertEquals(
|
|
647
|
-
response.events?.[0].message,
|
|
648
|
-
'Template test message at "2023-01-01T00:00:00.000Z"',
|
|
649
|
-
);
|
|
650
|
-
} finally {
|
|
651
|
-
// Always cleanup - delete log group (this also deletes log streams)
|
|
652
|
-
try {
|
|
653
|
-
await client.send(
|
|
654
|
-
new DeleteLogGroupCommand({
|
|
655
|
-
logGroupName: templateTestLogGroupName,
|
|
656
|
-
}),
|
|
657
|
-
);
|
|
658
|
-
} catch (error) {
|
|
659
|
-
console.warn("Failed to cleanup template test log group:", error);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
});
|
package/sink.test.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { suite } from "@alinea/suite";
|
|
2
2
|
import {
|
|
3
3
|
CloudWatchLogsClient,
|
|
4
|
-
CreateLogStreamCommand,
|
|
5
4
|
PutLogEventsCommand,
|
|
6
|
-
ResourceAlreadyExistsException,
|
|
7
5
|
} from "@aws-sdk/client-cloudwatch-logs";
|
|
8
6
|
import type { LogRecord } from "@logtape/logtape";
|
|
9
7
|
import { jsonLinesFormatter } from "@logtape/logtape";
|
|
@@ -82,11 +80,7 @@ test("getCloudWatchLogsSink() flushes when batch size is reached", async () => {
|
|
|
82
80
|
});
|
|
83
81
|
|
|
84
82
|
sink(mockLogRecord);
|
|
85
|
-
sink(mockLogRecord); // Should flush here
|
|
86
|
-
|
|
87
|
-
// Wait a bit to ensure flush happens
|
|
88
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
89
|
-
|
|
83
|
+
sink(mockLogRecord); // Should flush here
|
|
90
84
|
sink(mockLogRecord); // Should be in next batch
|
|
91
85
|
|
|
92
86
|
await sink[Symbol.asyncDispose](); // Should flush remaining
|
|
@@ -335,210 +329,3 @@ test("getCloudWatchLogsSink() uses default text formatter when no formatter prov
|
|
|
335
329
|
// Should be plain text, not JSON
|
|
336
330
|
assertEquals(logMessage, 'Hello, "world"!');
|
|
337
331
|
});
|
|
338
|
-
|
|
339
|
-
// Tests for auto-create log stream functionality
|
|
340
|
-
test("getCloudWatchLogsSink() automatically creates log stream when enabled", async () => {
|
|
341
|
-
const cwlMock = mockClient(CloudWatchLogsClient);
|
|
342
|
-
cwlMock.reset();
|
|
343
|
-
cwlMock.on(CreateLogStreamCommand).resolves({});
|
|
344
|
-
cwlMock.on(PutLogEventsCommand).resolves({});
|
|
345
|
-
|
|
346
|
-
const sink = getCloudWatchLogsSink({
|
|
347
|
-
logGroupName: "/test/log-group",
|
|
348
|
-
logStreamName: "test-stream",
|
|
349
|
-
autoCreateLogStream: true,
|
|
350
|
-
batchSize: 1,
|
|
351
|
-
flushInterval: 0,
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
sink(mockLogRecord);
|
|
355
|
-
await sink[Symbol.asyncDispose]();
|
|
356
|
-
|
|
357
|
-
assertEquals(cwlMock.commandCalls(CreateLogStreamCommand).length, 1);
|
|
358
|
-
assertEquals(cwlMock.commandCalls(PutLogEventsCommand).length, 1);
|
|
359
|
-
|
|
360
|
-
const createCall = cwlMock.commandCalls(CreateLogStreamCommand)[0];
|
|
361
|
-
assertEquals(createCall.args[0].input.logGroupName, "/test/log-group");
|
|
362
|
-
assertEquals(createCall.args[0].input.logStreamName, "test-stream");
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
test("getCloudWatchLogsSink() handles ResourceAlreadyExistsException gracefully", async () => {
|
|
366
|
-
const cwlMock = mockClient(CloudWatchLogsClient);
|
|
367
|
-
cwlMock.reset();
|
|
368
|
-
cwlMock.on(CreateLogStreamCommand).rejects(
|
|
369
|
-
new ResourceAlreadyExistsException({
|
|
370
|
-
message: "Log stream already exists",
|
|
371
|
-
$metadata: {},
|
|
372
|
-
}),
|
|
373
|
-
);
|
|
374
|
-
cwlMock.on(PutLogEventsCommand).resolves({});
|
|
375
|
-
|
|
376
|
-
const sink = getCloudWatchLogsSink({
|
|
377
|
-
logGroupName: "/test/log-group",
|
|
378
|
-
logStreamName: "existing-stream",
|
|
379
|
-
autoCreateLogStream: true,
|
|
380
|
-
batchSize: 1,
|
|
381
|
-
flushInterval: 0,
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
sink(mockLogRecord);
|
|
385
|
-
await sink[Symbol.asyncDispose]();
|
|
386
|
-
|
|
387
|
-
// Should still send the log event even though stream creation "failed"
|
|
388
|
-
assertEquals(cwlMock.commandCalls(CreateLogStreamCommand).length, 1);
|
|
389
|
-
assertEquals(cwlMock.commandCalls(PutLogEventsCommand).length, 1);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
test("getCloudWatchLogsSink() caches created streams to avoid redundant calls", async () => {
|
|
393
|
-
const cwlMock = mockClient(CloudWatchLogsClient);
|
|
394
|
-
cwlMock.reset();
|
|
395
|
-
cwlMock.on(CreateLogStreamCommand).resolves({});
|
|
396
|
-
cwlMock.on(PutLogEventsCommand).resolves({});
|
|
397
|
-
|
|
398
|
-
const sink = getCloudWatchLogsSink({
|
|
399
|
-
logGroupName: "/test/log-group",
|
|
400
|
-
logStreamName: "test-stream",
|
|
401
|
-
autoCreateLogStream: true,
|
|
402
|
-
batchSize: 1,
|
|
403
|
-
flushInterval: 0,
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
// Send multiple log events with delays to ensure separate batches
|
|
407
|
-
sink(mockLogRecord);
|
|
408
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
409
|
-
sink(mockLogRecord);
|
|
410
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
411
|
-
sink(mockLogRecord);
|
|
412
|
-
await sink[Symbol.asyncDispose]();
|
|
413
|
-
|
|
414
|
-
// Should only create the stream once, but send multiple events
|
|
415
|
-
assertEquals(cwlMock.commandCalls(CreateLogStreamCommand).length, 1);
|
|
416
|
-
assertEquals(cwlMock.commandCalls(PutLogEventsCommand).length, 3);
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
test("getCloudWatchLogsSink() does not create stream when autoCreateLogStream is false", async () => {
|
|
420
|
-
const cwlMock = mockClient(CloudWatchLogsClient);
|
|
421
|
-
cwlMock.reset();
|
|
422
|
-
cwlMock.on(PutLogEventsCommand).resolves({});
|
|
423
|
-
|
|
424
|
-
const sink = getCloudWatchLogsSink({
|
|
425
|
-
logGroupName: "/test/log-group",
|
|
426
|
-
logStreamName: "test-stream",
|
|
427
|
-
autoCreateLogStream: false,
|
|
428
|
-
batchSize: 1,
|
|
429
|
-
flushInterval: 0,
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
sink(mockLogRecord);
|
|
433
|
-
await sink[Symbol.asyncDispose]();
|
|
434
|
-
|
|
435
|
-
// Should not attempt to create stream
|
|
436
|
-
assertEquals(cwlMock.commandCalls(CreateLogStreamCommand).length, 0);
|
|
437
|
-
assertEquals(cwlMock.commandCalls(PutLogEventsCommand).length, 1);
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
test("getCloudWatchLogsSink() supports log stream name template", async () => {
|
|
441
|
-
const cwlMock = mockClient(CloudWatchLogsClient);
|
|
442
|
-
cwlMock.reset();
|
|
443
|
-
cwlMock.on(CreateLogStreamCommand).resolves({});
|
|
444
|
-
cwlMock.on(PutLogEventsCommand).resolves({});
|
|
445
|
-
|
|
446
|
-
const sink = getCloudWatchLogsSink({
|
|
447
|
-
logGroupName: "/test/log-group",
|
|
448
|
-
logStreamNameTemplate: "app-{YYYY-MM-DD}",
|
|
449
|
-
autoCreateLogStream: true,
|
|
450
|
-
batchSize: 1,
|
|
451
|
-
flushInterval: 0,
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
sink(mockLogRecord);
|
|
455
|
-
await sink[Symbol.asyncDispose]();
|
|
456
|
-
|
|
457
|
-
assertEquals(cwlMock.commandCalls(CreateLogStreamCommand).length, 1);
|
|
458
|
-
assertEquals(cwlMock.commandCalls(PutLogEventsCommand).length, 1);
|
|
459
|
-
|
|
460
|
-
const createCall = cwlMock.commandCalls(CreateLogStreamCommand)[0];
|
|
461
|
-
const putCall = cwlMock.commandCalls(PutLogEventsCommand)[0];
|
|
462
|
-
|
|
463
|
-
// Should use template-generated stream name
|
|
464
|
-
const streamName = createCall.args[0].input.logStreamName;
|
|
465
|
-
assertEquals(streamName?.startsWith("app-"), true);
|
|
466
|
-
assertEquals(streamName?.match(/app-\d{4}-\d{2}-\d{2}/) !== null, true);
|
|
467
|
-
|
|
468
|
-
// Both calls should use the same stream name
|
|
469
|
-
assertEquals(putCall.args[0].input.logStreamName, streamName);
|
|
470
|
-
});
|
|
471
|
-
|
|
472
|
-
test("getCloudWatchLogsSink() supports timestamp template", async () => {
|
|
473
|
-
const cwlMock = mockClient(CloudWatchLogsClient);
|
|
474
|
-
cwlMock.reset();
|
|
475
|
-
cwlMock.on(CreateLogStreamCommand).resolves({});
|
|
476
|
-
cwlMock.on(PutLogEventsCommand).resolves({});
|
|
477
|
-
|
|
478
|
-
const sink = getCloudWatchLogsSink({
|
|
479
|
-
logGroupName: "/test/log-group",
|
|
480
|
-
logStreamNameTemplate: "stream-{timestamp}",
|
|
481
|
-
autoCreateLogStream: true,
|
|
482
|
-
batchSize: 1,
|
|
483
|
-
flushInterval: 0,
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
sink(mockLogRecord);
|
|
487
|
-
await sink[Symbol.asyncDispose]();
|
|
488
|
-
|
|
489
|
-
const createCall = cwlMock.commandCalls(CreateLogStreamCommand)[0];
|
|
490
|
-
const streamName = createCall.args[0].input.logStreamName;
|
|
491
|
-
|
|
492
|
-
assertEquals(streamName?.startsWith("stream-"), true);
|
|
493
|
-
assertEquals(streamName?.match(/stream-\d+/) !== null, true);
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
test("getCloudWatchLogsSink() supports multiple template placeholders", async () => {
|
|
497
|
-
const cwlMock = mockClient(CloudWatchLogsClient);
|
|
498
|
-
cwlMock.reset();
|
|
499
|
-
cwlMock.on(CreateLogStreamCommand).resolves({});
|
|
500
|
-
cwlMock.on(PutLogEventsCommand).resolves({});
|
|
501
|
-
|
|
502
|
-
const sink = getCloudWatchLogsSink({
|
|
503
|
-
logGroupName: "/test/log-group",
|
|
504
|
-
logStreamNameTemplate: "app-{YYYY}-{MM}-{DD}-{timestamp}",
|
|
505
|
-
autoCreateLogStream: true,
|
|
506
|
-
batchSize: 1,
|
|
507
|
-
flushInterval: 0,
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
sink(mockLogRecord);
|
|
511
|
-
await sink[Symbol.asyncDispose]();
|
|
512
|
-
|
|
513
|
-
const createCall = cwlMock.commandCalls(CreateLogStreamCommand)[0];
|
|
514
|
-
const streamName = createCall.args[0].input.logStreamName;
|
|
515
|
-
|
|
516
|
-
assertEquals(streamName?.startsWith("app-"), true);
|
|
517
|
-
assertEquals(streamName?.match(/app-\d{4}-\d{2}-\d{2}-\d+/) !== null, true);
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
test("getCloudWatchLogsSink() prefers logStreamNameTemplate over logStreamName", async () => {
|
|
521
|
-
const cwlMock = mockClient(CloudWatchLogsClient);
|
|
522
|
-
cwlMock.reset();
|
|
523
|
-
cwlMock.on(CreateLogStreamCommand).resolves({});
|
|
524
|
-
cwlMock.on(PutLogEventsCommand).resolves({});
|
|
525
|
-
|
|
526
|
-
const sink = getCloudWatchLogsSink({
|
|
527
|
-
logGroupName: "/test/log-group",
|
|
528
|
-
logStreamName: "ignored-stream",
|
|
529
|
-
logStreamNameTemplate: "template-{timestamp}",
|
|
530
|
-
autoCreateLogStream: true,
|
|
531
|
-
batchSize: 1,
|
|
532
|
-
flushInterval: 0,
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
sink(mockLogRecord);
|
|
536
|
-
await sink[Symbol.asyncDispose]();
|
|
537
|
-
|
|
538
|
-
const createCall = cwlMock.commandCalls(CreateLogStreamCommand)[0];
|
|
539
|
-
const streamName = createCall.args[0].input.logStreamName;
|
|
540
|
-
|
|
541
|
-
// Should use template, not direct name
|
|
542
|
-
assertEquals(streamName?.startsWith("template-"), true);
|
|
543
|
-
assertEquals(streamName?.includes("ignored-stream"), false);
|
|
544
|
-
});
|
package/sink.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CloudWatchLogsClient,
|
|
3
|
-
CreateLogStreamCommand,
|
|
4
3
|
type InputLogEvent,
|
|
5
4
|
PutLogEventsCommand,
|
|
6
|
-
ResourceAlreadyExistsException,
|
|
7
5
|
} from "@aws-sdk/client-cloudwatch-logs";
|
|
8
6
|
import {
|
|
9
7
|
getLogger,
|
|
@@ -19,73 +17,6 @@ const MAX_BATCH_SIZE_EVENTS = 10000; // Maximum 10,000 events per batch
|
|
|
19
17
|
const MAX_BATCH_SIZE_BYTES = 1048576; // Maximum batch size: 1 MiB (1,048,576 bytes)
|
|
20
18
|
const OVERHEAD_PER_EVENT = 26; // AWS overhead per log event: 26 bytes per event
|
|
21
19
|
|
|
22
|
-
/**
|
|
23
|
-
* Resolves the log stream name from template.
|
|
24
|
-
* @param logStreamNameTemplate Template for generating stream names
|
|
25
|
-
* @returns Resolved log stream name
|
|
26
|
-
*/
|
|
27
|
-
function resolveLogStreamName(
|
|
28
|
-
logStreamNameTemplate: string,
|
|
29
|
-
): string {
|
|
30
|
-
const now = new Date();
|
|
31
|
-
const year = now.getFullYear().toString();
|
|
32
|
-
const month = (now.getMonth() + 1).toString().padStart(2, "0");
|
|
33
|
-
const day = now.getDate().toString().padStart(2, "0");
|
|
34
|
-
const timestamp = now.getTime().toString();
|
|
35
|
-
|
|
36
|
-
return logStreamNameTemplate
|
|
37
|
-
.replace(/\{YYYY\}/g, year)
|
|
38
|
-
.replace(/\{MM\}/g, month)
|
|
39
|
-
.replace(/\{DD\}/g, day)
|
|
40
|
-
.replace(/\{YYYY-MM-DD\}/g, `${year}-${month}-${day}`)
|
|
41
|
-
.replace(/\{timestamp\}/g, timestamp);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Ensures that the log stream exists, creating it if necessary.
|
|
46
|
-
* @param client CloudWatch Logs client
|
|
47
|
-
* @param logGroupName Log group name
|
|
48
|
-
* @param logStreamName Log stream name
|
|
49
|
-
* @param createdStreams Set to track already created streams
|
|
50
|
-
*/
|
|
51
|
-
async function ensureLogStreamExists(
|
|
52
|
-
client: CloudWatchLogsClient,
|
|
53
|
-
logGroupName: string,
|
|
54
|
-
logStreamName: string,
|
|
55
|
-
createdStreams: Set<string>,
|
|
56
|
-
): Promise<void> {
|
|
57
|
-
const streamKey = `${logGroupName}/${logStreamName}`;
|
|
58
|
-
|
|
59
|
-
// If we've already created this stream, skip
|
|
60
|
-
if (createdStreams.has(streamKey)) {
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
try {
|
|
65
|
-
const command = new CreateLogStreamCommand({
|
|
66
|
-
logGroupName,
|
|
67
|
-
logStreamName,
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
await client.send(command);
|
|
71
|
-
createdStreams.add(streamKey);
|
|
72
|
-
} catch (error) {
|
|
73
|
-
if (error instanceof ResourceAlreadyExistsException) {
|
|
74
|
-
// Stream already exists, this is fine
|
|
75
|
-
createdStreams.add(streamKey);
|
|
76
|
-
} else {
|
|
77
|
-
// Log stream creation failure to meta logger
|
|
78
|
-
const metaLogger = getLogger(["logtape", "meta", "cloudwatch-logs"]);
|
|
79
|
-
metaLogger.error(
|
|
80
|
-
"Failed to create log stream {logStreamName} in group {logGroupName}: {error}",
|
|
81
|
-
{ logStreamName, logGroupName, error },
|
|
82
|
-
);
|
|
83
|
-
// Re-throw other errors
|
|
84
|
-
throw error;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
20
|
/**
|
|
90
21
|
* Gets a CloudWatch Logs sink that sends log records to AWS CloudWatch Logs.
|
|
91
22
|
*
|
|
@@ -110,15 +41,6 @@ export function getCloudWatchLogsSink(
|
|
|
110
41
|
const maxRetries = Math.max(options.maxRetries ?? 3, 0);
|
|
111
42
|
const retryDelay = Math.max(options.retryDelay ?? 100, 0);
|
|
112
43
|
|
|
113
|
-
// Resolve the log stream name
|
|
114
|
-
const logStreamName =
|
|
115
|
-
options.autoCreateLogStream && "logStreamNameTemplate" in options
|
|
116
|
-
? resolveLogStreamName(options.logStreamNameTemplate)
|
|
117
|
-
: options.logStreamName;
|
|
118
|
-
|
|
119
|
-
// Track created streams to avoid redundant API calls
|
|
120
|
-
const createdStreams = new Set<string>();
|
|
121
|
-
|
|
122
44
|
// Default formatter that formats message parts into a simple string
|
|
123
45
|
const defaultFormatter: TextFormatter = (record) => {
|
|
124
46
|
let result = "";
|
|
@@ -138,7 +60,6 @@ export function getCloudWatchLogsSink(
|
|
|
138
60
|
let currentBatchSize = 0;
|
|
139
61
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
140
62
|
let disposed = false;
|
|
141
|
-
let flushPromise: Promise<void> | null = null;
|
|
142
63
|
|
|
143
64
|
function scheduleFlush(): void {
|
|
144
65
|
if (flushInterval <= 0 || flushTimer !== null) return;
|
|
@@ -154,21 +75,6 @@ export function getCloudWatchLogsSink(
|
|
|
154
75
|
async function flushEvents(): Promise<void> {
|
|
155
76
|
if (logEvents.length === 0 || disposed) return;
|
|
156
77
|
|
|
157
|
-
// If there's already a flush in progress, wait for it
|
|
158
|
-
if (flushPromise !== null) {
|
|
159
|
-
await flushPromise;
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Start a new flush operation
|
|
164
|
-
flushPromise = doFlush();
|
|
165
|
-
await flushPromise;
|
|
166
|
-
flushPromise = null;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function doFlush(): Promise<void> {
|
|
170
|
-
if (logEvents.length === 0 || disposed) return;
|
|
171
|
-
|
|
172
78
|
const events = logEvents.splice(0);
|
|
173
79
|
currentBatchSize = 0;
|
|
174
80
|
|
|
@@ -177,16 +83,6 @@ export function getCloudWatchLogsSink(
|
|
|
177
83
|
flushTimer = null;
|
|
178
84
|
}
|
|
179
85
|
|
|
180
|
-
// Auto-create log stream if enabled (only once per stream)
|
|
181
|
-
if (options.autoCreateLogStream) {
|
|
182
|
-
await ensureLogStreamExists(
|
|
183
|
-
client,
|
|
184
|
-
options.logGroupName,
|
|
185
|
-
logStreamName,
|
|
186
|
-
createdStreams,
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
86
|
await sendEventsWithRetry(events, maxRetries);
|
|
191
87
|
}
|
|
192
88
|
|
|
@@ -197,7 +93,7 @@ export function getCloudWatchLogsSink(
|
|
|
197
93
|
try {
|
|
198
94
|
const command = new PutLogEventsCommand({
|
|
199
95
|
logGroupName: options.logGroupName,
|
|
200
|
-
logStreamName: logStreamName,
|
|
96
|
+
logStreamName: options.logStreamName,
|
|
201
97
|
logEvents: events,
|
|
202
98
|
});
|
|
203
99
|
|
package/types.ts
CHANGED
|
@@ -5,122 +5,74 @@ import type { TextFormatter } from "@logtape/logtape";
|
|
|
5
5
|
* Options for configuring the CloudWatch Logs sink.
|
|
6
6
|
* @since 1.0.0
|
|
7
7
|
*/
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
readonly client?: CloudWatchLogsClient;
|
|
8
|
+
export interface CloudWatchLogsSinkOptions {
|
|
9
|
+
/**
|
|
10
|
+
* An existing CloudWatch Logs client instance.
|
|
11
|
+
* If provided, the client will be used directly and other connection
|
|
12
|
+
* options (region, credentials) will be ignored.
|
|
13
|
+
*/
|
|
14
|
+
readonly client?: CloudWatchLogsClient;
|
|
16
15
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The name of the log group to send log events to.
|
|
18
|
+
*/
|
|
19
|
+
readonly logGroupName: string;
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*/
|
|
27
|
-
readonly region?: string;
|
|
21
|
+
/**
|
|
22
|
+
* The name of the log stream within the log group.
|
|
23
|
+
*/
|
|
24
|
+
readonly logStreamName: string;
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
readonly accessKeyId: string;
|
|
36
|
-
readonly secretAccessKey: string;
|
|
37
|
-
readonly sessionToken?: string;
|
|
38
|
-
};
|
|
26
|
+
/**
|
|
27
|
+
* The AWS region to use when creating a new client.
|
|
28
|
+
* Ignored if `client` is provided.
|
|
29
|
+
* @default "us-east-1"
|
|
30
|
+
*/
|
|
31
|
+
readonly region?: string;
|
|
39
32
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
33
|
+
/**
|
|
34
|
+
* AWS credentials to use when creating a new client.
|
|
35
|
+
* Ignored if `client` is provided.
|
|
36
|
+
* If not provided, the AWS SDK will use default credential resolution.
|
|
37
|
+
*/
|
|
38
|
+
readonly credentials?: {
|
|
39
|
+
readonly accessKeyId: string;
|
|
40
|
+
readonly secretAccessKey: string;
|
|
41
|
+
readonly sessionToken?: string;
|
|
42
|
+
};
|
|
46
43
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Maximum number of log events to batch before sending to CloudWatch.
|
|
46
|
+
* Must be between 1 and 10,000.
|
|
47
|
+
* @default 1000
|
|
48
|
+
*/
|
|
49
|
+
readonly batchSize?: number;
|
|
53
50
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Maximum time in milliseconds to wait before flushing buffered log events.
|
|
53
|
+
* Set to 0 or negative to disable time-based flushing.
|
|
54
|
+
* @default 1000
|
|
55
|
+
*/
|
|
56
|
+
readonly flushInterval?: number;
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Maximum number of retry attempts for failed requests.
|
|
60
|
+
* @default 3
|
|
61
|
+
*/
|
|
62
|
+
readonly maxRetries?: number;
|
|
65
63
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
* @since 1.0.0
|
|
72
|
-
*/
|
|
73
|
-
readonly formatter?: TextFormatter;
|
|
74
|
-
}
|
|
75
|
-
& (
|
|
76
|
-
| {
|
|
77
|
-
/**
|
|
78
|
-
* Whether to automatically create the log stream if it doesn't exist.
|
|
79
|
-
* When enabled, the sink will attempt to create the specified log stream
|
|
80
|
-
* before sending log events. If the stream already exists, the creation
|
|
81
|
-
* attempt will be safely ignored.
|
|
82
|
-
* @default false
|
|
83
|
-
* @since 1.1.0
|
|
84
|
-
*/
|
|
85
|
-
readonly autoCreateLogStream?: false;
|
|
64
|
+
/**
|
|
65
|
+
* Initial delay in milliseconds for exponential backoff retry strategy.
|
|
66
|
+
* @default 100
|
|
67
|
+
*/
|
|
68
|
+
readonly retryDelay?: number;
|
|
86
69
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* When enabled, the sink will attempt to create the specified log stream
|
|
97
|
-
* before sending log events. If the stream already exists, the creation
|
|
98
|
-
* attempt will be safely ignored.
|
|
99
|
-
* @default false
|
|
100
|
-
* @since 1.1.0
|
|
101
|
-
*/
|
|
102
|
-
readonly autoCreateLogStream: true;
|
|
103
|
-
}
|
|
104
|
-
& ({
|
|
105
|
-
/**
|
|
106
|
-
* The name of the log stream within the log group.
|
|
107
|
-
* Required unless `logStreamNameTemplate` is provided.
|
|
108
|
-
*/
|
|
109
|
-
readonly logStreamName: string;
|
|
110
|
-
} | {
|
|
111
|
-
/**
|
|
112
|
-
* Template for generating dynamic log stream names.
|
|
113
|
-
* Supports the following placeholders:
|
|
114
|
-
* - `{YYYY}`: 4-digit year
|
|
115
|
-
* - `{MM}`: 2-digit month (01-12)
|
|
116
|
-
* - `{DD}`: 2-digit day (01-31)
|
|
117
|
-
* - `{YYYY-MM-DD}`: Date in YYYY-MM-DD format
|
|
118
|
-
* - `{timestamp}`: Unix timestamp in milliseconds
|
|
119
|
-
*
|
|
120
|
-
* If provided, this will be used instead of `logStreamName`.
|
|
121
|
-
* Only used when `autoCreateLogStream` is true.
|
|
122
|
-
* @since 1.1.0
|
|
123
|
-
*/
|
|
124
|
-
readonly logStreamNameTemplate: string;
|
|
125
|
-
})
|
|
126
|
-
);
|
|
70
|
+
/**
|
|
71
|
+
* Text formatter to use for formatting log records before sending to CloudWatch Logs.
|
|
72
|
+
* If not provided, defaults to a simple text formatter.
|
|
73
|
+
* Use `jsonLinesFormatter()` from "@logtape/logtape" for JSON structured logging
|
|
74
|
+
* to enable powerful CloudWatch Logs Insights querying capabilities.
|
|
75
|
+
* @since 1.0.0
|
|
76
|
+
*/
|
|
77
|
+
readonly formatter?: TextFormatter;
|
|
78
|
+
}
|