fix: Logging large objects is now much more performant and uses less memory (#2263)

* Logging large objects performance fixes

* Add changeset

* docs: new otel attribute count defaults

* Scope the switch/case statement with braces

* Add tests for nested map and sets

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
This commit is contained in:
Eric Allam
2025-07-14 13:16:04 +01:00
committed by GitHub
parent 87c21ab50a
commit 70b4b1257e
13 changed files with 639 additions and 111 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
fix: Logging large objects is now much more performant and uses less memory
+2 -2
View File
@@ -283,8 +283,8 @@ const EnvironmentSchema = z.object({
PROD_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
PROD_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: z.string().default("256"),
TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT: z.string().default("256"),
TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
TRIGGER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT: z.string().default("131072"),
TRIGGER_OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT: z.string().default("131072"),
TRIGGER_OTEL_SPAN_EVENT_COUNT_LIMIT: z.string().default("10"),
+2 -2
View File
@@ -105,8 +105,8 @@ mode: "wide"
| `MAXIMUM_DEV_QUEUE_SIZE` | No | — | Max dev queue size. |
| `MAXIMUM_DEPLOYED_QUEUE_SIZE` | No | — | Max deployed queue size. |
| **OTel limits** | | | |
| `TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT` | No | 256 | OTel span attribute count limit. |
| `TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT` | No | 256 | OTel log attribute count limit. |
| `TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT` | No | 1024 | OTel span attribute count limit. |
| `TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT` | No | 1024 | OTel log attribute count limit. |
| `TRIGGER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT` | No | 131072 | OTel span attribute value length limit. |
| `TRIGGER_OTEL_LOG_ATTRIBUTE_VALUE_LENGTH_LIMIT` | No | 131072 | OTel log attribute value length limit. |
| `TRIGGER_OTEL_SPAN_EVENT_COUNT_LIMIT` | No | 10 | OTel span event count limit. |
@@ -15,6 +15,7 @@ import {
localsAPI,
logger,
LogLevel,
OTEL_LOG_ATTRIBUTE_COUNT_LIMIT,
resourceCatalog,
runMetadata,
runtime,
@@ -191,7 +192,8 @@ async function doBootstrap() {
typeof config.enableConsoleLogging === "boolean" ? config.enableConsoleLogging : true,
typeof config.disableConsoleInterceptor === "boolean"
? config.disableConsoleInterceptor
: false
: false,
OTEL_LOG_ATTRIBUTE_COUNT_LIMIT
);
const configLogLevel = triggerLogLevel ?? config.logLevel ?? "info";
@@ -200,6 +202,7 @@ async function doBootstrap() {
logger: otelLogger,
tracer: tracer,
level: logLevels.includes(configLogLevel as any) ? (configLogLevel as LogLevel) : "info",
maxAttributeCount: OTEL_LOG_ATTRIBUTE_COUNT_LIMIT,
});
logger.setGlobalTaskLogger(otelTaskLogger);
@@ -14,6 +14,7 @@ import {
localsAPI,
logger,
LogLevel,
OTEL_LOG_ATTRIBUTE_COUNT_LIMIT,
resourceCatalog,
runMetadata,
runtime,
@@ -182,7 +183,8 @@ async function doBootstrap() {
typeof config.enableConsoleLogging === "boolean" ? config.enableConsoleLogging : true,
typeof config.disableConsoleInterceptor === "boolean"
? config.disableConsoleInterceptor
: false
: false,
OTEL_LOG_ATTRIBUTE_COUNT_LIMIT
);
const configLogLevel = triggerLogLevel ?? config.logLevel ?? "info";
@@ -191,6 +193,7 @@ async function doBootstrap() {
logger: otelLogger,
tracer: tracer,
level: logLevels.includes(configLogLevel as any) ? (configLogLevel as LogLevel) : "info",
maxAttributeCount: OTEL_LOG_ATTRIBUTE_COUNT_LIMIT,
});
logger.setGlobalTaskLogger(otelTaskLogger);
+6 -2
View File
@@ -11,7 +11,8 @@ export class ConsoleInterceptor {
constructor(
private readonly logger: logsAPI.Logger,
private readonly sendToStdIO: boolean,
private readonly interceptingDisabled: boolean
private readonly interceptingDisabled: boolean,
private readonly maxAttributeCount?: number
) {}
// Intercept the console and send logs to the OpenTelemetry logger
@@ -92,7 +93,10 @@ export class ConsoleInterceptor {
severityNumber,
severityText,
body: getLogMessage(parsed.value, severityText),
attributes: { ...this.#getAttributes(severityNumber), ...flattenAttributes(parsed.value) },
attributes: {
...this.#getAttributes(severityNumber),
...flattenAttributes(parsed.value, undefined, this.maxAttributeCount),
},
timestamp,
});
+2 -6
View File
@@ -13,11 +13,11 @@ function getOtelEnvVarLimit(key: string, defaultValue: number) {
export const OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT = getOtelEnvVarLimit(
"TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT",
256
1024
);
export const OTEL_LOG_ATTRIBUTE_COUNT_LIMIT = getOtelEnvVarLimit(
"TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT",
256
1024
);
export const OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT = getOtelEnvVarLimit(
"TRIGGER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT",
@@ -51,10 +51,6 @@ export function imposeAttributeLimits(attributes: Attributes): Attributes {
continue;
}
if (Object.keys(newAttributes).length >= OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT) {
break;
}
newAttributes[key] = value;
}
+7 -21
View File
@@ -16,6 +16,7 @@ export type TaskLoggerConfig = {
logger: Logger;
tracer: TriggerTracer;
level: LogLevel;
maxAttributeCount?: number;
};
export type TraceOptions = Prettify<
@@ -78,7 +79,12 @@ export class OtelTaskLogger implements TaskLogger {
severityNumber: SeverityNumber,
properties?: Record<string, unknown>
) {
let attributes: Attributes = { ...flattenAttributes(safeJsonProcess(properties)) };
let attributes: Attributes = {};
if (properties) {
// Use flattenAttributes directly - it now handles all non-JSON friendly values efficiently
attributes = flattenAttributes(properties, undefined, this._config.maxAttributeCount);
}
const icon = iconStringForSeverity(severityNumber);
if (icon !== undefined) {
@@ -136,23 +142,3 @@ export class NoopTaskLogger implements TaskLogger {
return {} as Span;
}
}
function safeJsonProcess(value?: Record<string, unknown>): Record<string, unknown> | undefined {
try {
return JSON.parse(JSON.stringify(value, jsonErrorReplacer));
} catch {
return value;
}
}
function jsonErrorReplacer(key: string, value: unknown) {
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack,
};
}
return value;
}
+205 -56
View File
@@ -4,81 +4,230 @@ export const NULL_SENTINEL = "$@null((";
export const CIRCULAR_REFERENCE_SENTINEL = "$@circular((";
export function flattenAttributes(
obj: Record<string, unknown> | Array<unknown> | string | boolean | number | null | undefined,
obj: unknown,
prefix?: string,
seen: WeakSet<object> = new WeakSet()
maxAttributeCount?: number
): Attributes {
const result: Attributes = {};
const flattener = new AttributeFlattener(maxAttributeCount);
flattener.doFlatten(obj, prefix);
return flattener.attributes;
}
// Check if obj is null or undefined
if (obj === undefined) {
return result;
class AttributeFlattener {
private seen: WeakSet<object> = new WeakSet();
private attributeCounter: number = 0;
private result: Attributes = {};
constructor(private maxAttributeCount?: number) {}
get attributes(): Attributes {
return this.result;
}
if (obj === null) {
result[prefix || ""] = NULL_SENTINEL;
return result;
private canAddMoreAttributes(): boolean {
return this.maxAttributeCount === undefined || this.attributeCounter < this.maxAttributeCount;
}
if (typeof obj === "string") {
result[prefix || ""] = obj;
return result;
private addAttribute(key: string, value: any): boolean {
if (!this.canAddMoreAttributes()) {
return false;
}
this.result[key] = value;
this.attributeCounter++;
return true;
}
if (typeof obj === "number") {
result[prefix || ""] = obj;
return result;
}
doFlatten(obj: unknown, prefix?: string) {
if (!this.canAddMoreAttributes()) {
return;
}
if (typeof obj === "boolean") {
result[prefix || ""] = obj;
return result;
}
// Check if obj is null or undefined
if (obj === undefined) {
return;
}
if (obj instanceof Date) {
result[prefix || ""] = obj.toISOString();
return result;
}
if (obj === null) {
this.addAttribute(prefix || "", NULL_SENTINEL);
return;
}
// Check for circular reference
if (obj !== null && typeof obj === "object" && seen.has(obj)) {
result[prefix || ""] = CIRCULAR_REFERENCE_SENTINEL;
return result;
}
if (typeof obj === "string") {
this.addAttribute(prefix || "", obj);
return;
}
// Add object to seen set
if (obj !== null && typeof obj === "object") {
seen.add(obj);
}
if (typeof obj === "number") {
this.addAttribute(prefix || "", obj);
return;
}
for (const [key, value] of Object.entries(obj)) {
const newPrefix = `${prefix ? `${prefix}.` : ""}${Array.isArray(obj) ? `[${key}]` : key}`;
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
if (typeof value[i] === "object" && value[i] !== null) {
// update null check here as well
Object.assign(result, flattenAttributes(value[i], `${newPrefix}.[${i}]`, seen));
} else {
if (value[i] === null) {
result[`${newPrefix}.[${i}]`] = NULL_SENTINEL;
} else {
result[`${newPrefix}.[${i}]`] = value[i];
}
}
if (typeof obj === "boolean") {
this.addAttribute(prefix || "", obj);
return;
}
if (obj instanceof Date) {
this.addAttribute(prefix || "", obj.toISOString());
return;
}
// Handle Error objects
if (obj instanceof Error) {
this.addAttribute(`${prefix || "error"}.name`, obj.name);
this.addAttribute(`${prefix || "error"}.message`, obj.message);
if (obj.stack) {
this.addAttribute(`${prefix || "error"}.stack`, obj.stack);
}
} else if (isRecord(value)) {
// update null check here
Object.assign(result, flattenAttributes(value, newPrefix, seen));
} else {
if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") {
result[newPrefix] = value;
} else if (value === null) {
result[newPrefix] = NULL_SENTINEL;
return;
}
// Handle functions
if (typeof obj === "function") {
const funcName = obj.name || "anonymous";
this.addAttribute(prefix || "", `[Function: ${funcName}]`);
return;
}
// Handle Set objects
if (obj instanceof Set) {
let index = 0;
for (const item of obj) {
if (!this.canAddMoreAttributes()) break;
this.#processValue(item, `${prefix || "set"}.[${index}]`);
index++;
}
return;
}
// Handle Map objects
if (obj instanceof Map) {
for (const [key, value] of obj) {
if (!this.canAddMoreAttributes()) break;
// Use the key directly if it's a string, otherwise convert it
const keyStr = typeof key === "string" ? key : String(key);
this.#processValue(value, `${prefix || "map"}.${keyStr}`);
}
return;
}
// Handle File objects
if (typeof File !== "undefined" && obj instanceof File) {
this.addAttribute(`${prefix || "file"}.name`, obj.name);
this.addAttribute(`${prefix || "file"}.size`, obj.size);
this.addAttribute(`${prefix || "file"}.type`, obj.type);
this.addAttribute(`${prefix || "file"}.lastModified`, obj.lastModified);
return;
}
// Handle ReadableStream objects
if (typeof ReadableStream !== "undefined" && obj instanceof ReadableStream) {
this.addAttribute(`${prefix || "stream"}.type`, "ReadableStream");
this.addAttribute(`${prefix || "stream"}.locked`, obj.locked);
return;
}
// Handle WritableStream objects
if (typeof WritableStream !== "undefined" && obj instanceof WritableStream) {
this.addAttribute(`${prefix || "stream"}.type`, "WritableStream");
this.addAttribute(`${prefix || "stream"}.locked`, obj.locked);
return;
}
// Handle Promise objects
if (obj instanceof Promise) {
this.addAttribute(prefix || "promise", "[Promise object]");
// We can't inspect promise state synchronously, so just indicate it's a promise
return;
}
// Handle RegExp objects
if (obj instanceof RegExp) {
this.addAttribute(`${prefix || "regexp"}.source`, obj.source);
this.addAttribute(`${prefix || "regexp"}.flags`, obj.flags);
return;
}
// Handle URL objects
if (typeof URL !== "undefined" && obj instanceof URL) {
this.addAttribute(`${prefix || "url"}.href`, obj.href);
this.addAttribute(`${prefix || "url"}.protocol`, obj.protocol);
this.addAttribute(`${prefix || "url"}.host`, obj.host);
this.addAttribute(`${prefix || "url"}.pathname`, obj.pathname);
return;
}
// Handle ArrayBuffer and TypedArrays
if (obj instanceof ArrayBuffer) {
this.addAttribute(`${prefix || "arraybuffer"}.byteLength`, obj.byteLength);
return;
}
// Handle TypedArrays (Uint8Array, Int32Array, etc.)
if (ArrayBuffer.isView(obj)) {
const typedArray = obj as any;
this.addAttribute(`${prefix || "typedarray"}.constructor`, typedArray.constructor.name);
this.addAttribute(`${prefix || "typedarray"}.length`, typedArray.length);
this.addAttribute(`${prefix || "typedarray"}.byteLength`, typedArray.byteLength);
this.addAttribute(`${prefix || "typedarray"}.byteOffset`, typedArray.byteOffset);
return;
}
// Check for circular reference
if (obj !== null && typeof obj === "object" && this.seen.has(obj)) {
this.addAttribute(prefix || "", CIRCULAR_REFERENCE_SENTINEL);
return;
}
// Add object to seen set
if (obj !== null && typeof obj === "object") {
this.seen.add(obj);
}
for (const [key, value] of Object.entries(obj)) {
if (!this.canAddMoreAttributes()) {
break;
}
const newPrefix = `${prefix ? `${prefix}.` : ""}${Array.isArray(obj) ? `[${key}]` : key}`;
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
if (!this.canAddMoreAttributes()) {
break;
}
this.#processValue(value[i], `${newPrefix}.[${i}]`);
}
} else {
this.#processValue(value, newPrefix);
}
}
}
return result;
#processValue(value: unknown, prefix: string) {
if (!this.canAddMoreAttributes()) {
return;
}
// Handle primitive values directly
if (value === null) {
this.addAttribute(prefix, NULL_SENTINEL);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
this.addAttribute(prefix, value);
return;
}
// Handle non-primitive values by recursing
if (typeof value === "object" || typeof value === "function") {
this.doFlatten(value as any, prefix);
} else {
// Convert other types to strings (bigint, symbol, etc.)
this.addAttribute(prefix, String(value));
}
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
+24 -12
View File
@@ -1,13 +1,17 @@
import { Attributes, Span } from "@opentelemetry/api";
import { OFFLOAD_IO_PACKET_LENGTH_LIMIT, imposeAttributeLimits } from "../limits.js";
import { z } from "zod";
import { ApiClient } from "../apiClient/index.js";
import { apiClientManager } from "../apiClientManager-api.js";
import {
OFFLOAD_IO_PACKET_LENGTH_LIMIT,
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT,
imposeAttributeLimits,
} from "../limits.js";
import type { RetryOptions } from "../schemas/index.js";
import { SemanticInternalAttributes } from "../semanticInternalAttributes.js";
import { TriggerTracer } from "../tracer.js";
import { flattenAttributes } from "./flattenAttributes.js";
import { apiClientManager } from "../apiClientManager-api.js";
import { zodfetch } from "../zodfetch.js";
import { z } from "zod";
import type { RetryOptions } from "../schemas/index.js";
import { ApiClient } from "../apiClient/index.js";
import { flattenAttributes } from "./flattenAttributes.js";
export type IOPacket = {
data?: string | undefined;
@@ -347,19 +351,27 @@ export async function createPacketAttributesAsJson(
}
switch (dataType) {
case "application/json":
return imposeAttributeLimits(flattenAttributes(data, undefined));
case "application/super+json":
case "application/json": {
return imposeAttributeLimits(
flattenAttributes(data, undefined, OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT)
);
}
case "application/super+json": {
const { deserialize } = await loadSuperJSON();
const deserialized = deserialize(data) as any;
const jsonify = safeJsonParse(JSON.stringify(deserialized, makeSafeReplacer()));
return imposeAttributeLimits(flattenAttributes(jsonify, undefined));
case "application/store":
return imposeAttributeLimits(
flattenAttributes(jsonify, undefined, OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT)
);
}
case "application/store": {
return data;
default:
}
default: {
return {};
}
}
}
+7 -2
View File
@@ -15,6 +15,7 @@ import {
attemptKey,
flattenAttributes,
lifecycleHooks,
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT,
runMetadata,
waitUntil,
} from "../index.js";
@@ -716,7 +717,9 @@ export class TaskExecutor {
const result = await hook.fn({ payload, ctx, signal, task: this.task.id });
if (result && typeof result === "object" && !Array.isArray(result)) {
span.setAttributes(flattenAttributes(result));
span.setAttributes(
flattenAttributes(result, undefined, OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT)
);
return result;
}
@@ -752,7 +755,9 @@ export class TaskExecutor {
const result = await taskInitHook({ payload, ctx, signal, task: this.task.id });
if (result && typeof result === "object" && !Array.isArray(result)) {
span.setAttributes(flattenAttributes(result));
span.setAttributes(
flattenAttributes(result, undefined, OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT)
);
return result;
}
+322 -6
View File
@@ -1,7 +1,7 @@
import { flattenAttributes, unflattenAttributes } from "../src/v3/utils/flattenAttributes.js";
describe("flattenAttributes", () => {
it("handles number keys correctl", () => {
it("handles number keys correctly", () => {
expect(flattenAttributes({ bar: { "25": "foo" } })).toEqual({ "bar.25": "foo" });
expect(unflattenAttributes({ "bar.25": "foo" })).toEqual({ bar: { "25": "foo" } });
expect(flattenAttributes({ bar: ["foo", "baz"] })).toEqual({
@@ -157,13 +157,14 @@ describe("flattenAttributes", () => {
expect(flattenAttributes(obj, "retry.byStatus")).toEqual(expected);
});
it("handles circular references correctly", () => {
it("handles circular references correctly", () => {
const user = { name: "Alice" };
// @ts-expect-error
user["blogPosts"] = [{ title: "Post 1", author: user }]; // Circular reference
const result = flattenAttributes(user);
expect(result).toEqual({
"name": "Alice",
name: "Alice",
"blogPosts.[0].title": "Post 1",
"blogPosts.[0].author": "$@circular((",
});
@@ -171,14 +172,329 @@ describe("flattenAttributes", () => {
it("handles nested circular references correctly", () => {
const user = { name: "Bob" };
// @ts-expect-error
user["friends"] = [user]; // Circular reference
const result = flattenAttributes(user);
expect(result).toEqual({
"name": "Bob",
name: "Bob",
"friends.[0]": "$@circular((",
});
});
it("respects maxAttributeCount limit", () => {
const obj = {
a: 1,
b: 2,
c: 3,
d: 4,
e: 5,
};
const result = flattenAttributes(obj, undefined, 3);
expect(Object.keys(result)).toHaveLength(3);
expect(result).toEqual({
a: 1,
b: 2,
c: 3,
});
});
it("respects maxAttributeCount limit with nested objects", () => {
const obj = {
level1: {
a: 1,
b: 2,
c: 3,
},
level2: {
d: 4,
e: 5,
},
};
const result = flattenAttributes(obj, undefined, 2);
expect(Object.keys(result)).toHaveLength(2);
expect(result).toEqual({
"level1.a": 1,
"level1.b": 2,
});
});
it("respects maxAttributeCount limit with arrays", () => {
const obj = {
array: [1, 2, 3, 4, 5],
};
const result = flattenAttributes(obj, undefined, 3);
expect(Object.keys(result)).toHaveLength(3);
expect(result).toEqual({
"array.[0]": 1,
"array.[1]": 2,
"array.[2]": 3,
});
});
it("works normally when maxAttributeCount is undefined", () => {
const obj = {
a: 1,
b: 2,
c: 3,
};
const result = flattenAttributes(obj);
expect(Object.keys(result)).toHaveLength(3);
expect(result).toEqual({
a: 1,
b: 2,
c: 3,
});
});
it("handles maxAttributeCount of 0", () => {
const obj = {
a: 1,
b: 2,
};
const result = flattenAttributes(obj, undefined, 0);
expect(Object.keys(result)).toHaveLength(0);
expect(result).toEqual({});
});
it("handles maxAttributeCount with primitive values", () => {
const result1 = flattenAttributes("test", undefined, 1);
expect(result1).toEqual({ "": "test" });
const result2 = flattenAttributes("test", undefined, 0);
expect(result2).toEqual({});
});
it("handles Error objects correctly", () => {
const error = new Error("Test error message");
error.stack = "Error: Test error message\n at test.js:1:1";
const result = flattenAttributes({ error });
expect(result).toEqual({
"error.name": "Error",
"error.message": "Test error message",
"error.stack": "Error: Test error message\n at test.js:1:1",
});
});
it("handles Error objects as top-level values", () => {
const error = new Error("Top level error");
const result = flattenAttributes(error);
expect(result["error.name"]).toBe("Error");
expect(result["error.message"]).toBe("Top level error");
// Stack trace is also included when present
expect(result["error.stack"]).toBeDefined();
});
it("handles function values correctly", () => {
function namedFunction() {}
const anonymousFunction = function () {};
const arrowFunction = () => {};
const result = flattenAttributes({
named: namedFunction,
anonymous: anonymousFunction,
arrow: arrowFunction,
});
expect(result.named).toBe("[Function: namedFunction]");
// Note: function expressions with variable names retain their names
expect(result.anonymous).toBe("[Function: anonymousFunction]");
// Arrow functions also get their variable names in modern JS
expect(result.arrow).toBe("[Function: arrowFunction]");
});
it("handles mixed problematic types", () => {
const complexObj = {
error: new Error("Mixed error"),
func: function testFunc() {},
date: new Date("2023-01-01"),
normal: "string",
number: 42,
};
const result = flattenAttributes(complexObj);
expect(result["error.name"]).toBe("Error");
expect(result["error.message"]).toBe("Mixed error");
expect(result["func"]).toBe("[Function: testFunc]");
expect(result["date"]).toBe("2023-01-01T00:00:00.000Z");
expect(result["normal"]).toBe("string");
expect(result["number"]).toBe(42);
});
it("handles bigint and symbol types", () => {
const obj = {
bigNumber: BigInt(123456789),
sym: Symbol("test"),
};
const result = flattenAttributes(obj);
expect(result["bigNumber"]).toBe("123456789");
expect(result["sym"]).toBe("Symbol(test)");
});
it("handles Set objects correctly", () => {
const mySet = new Set([1, "hello", true, { nested: "object" }]);
const result = flattenAttributes({ mySet });
expect(result["mySet.[0]"]).toBe(1);
expect(result["mySet.[1]"]).toBe("hello");
expect(result["mySet.[2]"]).toBe(true);
expect(result["mySet.[3].nested"]).toBe("object");
});
it("handles nested Set objects correctly", () => {
const mySet = new Set([1, 2, 3, { nested: "object" }]);
const result = flattenAttributes({ mySet });
expect(result["mySet.[0]"]).toBe(1);
expect(result["mySet.[1]"]).toBe(2);
expect(result["mySet.[2]"]).toBe(3);
expect(result["mySet.[3].nested"]).toBe("object");
});
it("handles Map objects correctly", () => {
const myMap = new Map();
myMap.set("key1", "value1");
myMap.set("key2", 42);
myMap.set(123, "numeric key");
const result = flattenAttributes({ myMap });
expect(result["myMap.key1"]).toBe("value1");
expect(result["myMap.key2"]).toBe(42);
expect(result["myMap.123"]).toBe("numeric key");
});
it("handles nested Map objects correctly", () => {
const myMap = new Map();
myMap.set("key1", {
key2: "value2",
key3: 42,
});
const result = flattenAttributes({ myMap });
expect(result["myMap.key1.key2"]).toBe("value2");
expect(result["myMap.key1.key3"]).toBe(42);
});
it("handles File objects correctly", () => {
if (typeof File !== "undefined") {
const file = new File(["content"], "test.txt", {
type: "text/plain",
lastModified: 1640995200000,
});
const result = flattenAttributes({ file });
expect(result["file.name"]).toBe("test.txt");
expect(result["file.type"]).toBe("text/plain");
expect(result["file.size"]).toBe(7); // "content" is 7 bytes
expect(result["file.lastModified"]).toBe(1640995200000);
}
});
it("handles ReadableStream objects correctly", () => {
if (typeof ReadableStream !== "undefined") {
const stream = new ReadableStream();
const result = flattenAttributes({ stream });
expect(result["stream.type"]).toBe("ReadableStream");
expect(result["stream.locked"]).toBe(false);
}
});
it("handles Promise objects correctly", () => {
const resolvedPromise = Promise.resolve("value");
const rejectedPromise = Promise.reject(new Error("failed"));
const pendingPromise = new Promise(() => {}); // Never resolves
// Catch the rejection to avoid unhandled promise rejection warnings
rejectedPromise.catch(() => {});
const result = flattenAttributes({
resolved: resolvedPromise,
rejected: rejectedPromise,
pending: pendingPromise,
});
expect(result["resolved"]).toBe("[Promise object]");
expect(result["rejected"]).toBe("[Promise object]");
expect(result["pending"]).toBe("[Promise object]");
});
it("handles RegExp objects correctly", () => {
const regex = /hello.*world/gim;
const result = flattenAttributes({ regex });
expect(result["regex.source"]).toBe("hello.*world");
expect(result["regex.flags"]).toBe("gim");
});
it("handles URL objects correctly", () => {
if (typeof URL !== "undefined") {
const url = new URL("https://example.com:8080/path?query=value#fragment");
const result = flattenAttributes({ url });
expect(result["url.href"]).toBe("https://example.com:8080/path?query=value#fragment");
expect(result["url.protocol"]).toBe("https:");
expect(result["url.host"]).toBe("example.com:8080");
expect(result["url.pathname"]).toBe("/path");
}
});
it("handles ArrayBuffer correctly", () => {
const buffer = new ArrayBuffer(16);
const result = flattenAttributes({ buffer });
expect(result["buffer.byteLength"]).toBe(16);
});
it("handles TypedArrays correctly", () => {
const uint8Array = new Uint8Array([1, 2, 3, 4]);
const int32Array = new Int32Array([100, 200, 300]);
const result = flattenAttributes({
uint8: uint8Array,
int32: int32Array,
});
expect(result["uint8.constructor"]).toBe("Uint8Array");
expect(result["uint8.length"]).toBe(4);
expect(result["uint8.byteLength"]).toBe(4);
expect(result["uint8.byteOffset"]).toBe(0);
expect(result["int32.constructor"]).toBe("Int32Array");
expect(result["int32.length"]).toBe(3);
expect(result["int32.byteLength"]).toBe(12); // 3 * 4 bytes
expect(result["int32.byteOffset"]).toBe(0);
});
it("handles complex mixed object with all special types", () => {
const complexObj = {
error: new Error("Test error"),
func: function testFunc() {},
date: new Date("2023-01-01"),
mySet: new Set([1, 2, 3]),
myMap: new Map([["key", "value"]]),
regex: /test/gi,
bigint: BigInt(999),
symbol: Symbol("test"),
};
const result = flattenAttributes(complexObj);
// Verify we get reasonable representations for all types
expect(result["error.name"]).toBe("Error");
expect(result["func"]).toBe("[Function: testFunc]");
expect(result["date"]).toBe("2023-01-01T00:00:00.000Z");
expect(result["regex.source"]).toBe("test");
expect(result["bigint"]).toBe("999");
expect(typeof result["symbol"]).toBe("string");
});
});
describe("unflattenAttributes", () => {
@@ -246,10 +562,10 @@ describe("unflattenAttributes", () => {
};
expect(unflattenAttributes(flattened)).toEqual(expected);
});
it("rehydrates circular references correctly", () => {
const flattened = {
"name": "Alice",
name: "Alice",
"blogPosts.[0].title": "Post 1",
"blogPosts.[0].author": "$@circular((",
};
@@ -328,3 +328,52 @@ export const circularReferenceTask = task({
};
},
});
export const largeAttributesTask = task({
id: "large-attributes",
machine: "large-1x",
run: async ({ length = 100000 }: { length: number }, { signal, ctx }) => {
// Create a large deeply nested object/array of objects that have more than 10k attributes when flattened
const start = performance.now();
const largeObject = Array.from({ length }, (_, i) => ({
a: i,
b: i,
c: i,
}));
const end = performance.now();
console.log(`[${length}] Time taken to create the large object: ${end - start}ms`);
const start2 = performance.now();
logger.info("Hello, world from the large attributes task", { largeObject });
const end2 = performance.now();
console.log(`[${length}] Time taken to log the large object: ${end2 - start2}ms`);
class MyClass {
constructor(public name: string) {}
}
logger.info("Lets log some weird stuff", {
error: new Error("This is an error"),
func: () => {
logger.info("This is a function");
},
date: new Date(),
bigInt: BigInt(1000000000000000000),
symbol: Symbol("symbol"),
myClass: new MyClass("MyClass"),
file: new File([], "test.txt"),
stream: new ReadableStream(),
map: new Map([["key", "value"]]),
set: new Set([1, 2, 3]),
promise: Promise.resolve("Hello, world!"),
promiseRejected: Promise.reject(new Error("This is a rejected promise")),
promisePending: Promise.resolve("Hello, world!"),
});
},
});