diff --git a/packages/cli-v3/src/cli/common.ts b/packages/cli-v3/src/cli/common.ts index f251e4e5ef4..402fa29a0dd 100644 --- a/packages/cli-v3/src/cli/common.ts +++ b/packages/cli-v3/src/cli/common.ts @@ -13,14 +13,17 @@ export const CommonCommandOptions = z.object({ apiUrl: z.string().optional(), logLevel: z.enum(["debug", "info", "log", "warn", "error", "none"]).default("log"), skipTelemetry: z.boolean().default(false), - profile: z.string().default(readAuthConfigCurrentProfileName()), + profile: z + .string() + .optional() + .transform((v) => v ?? readAuthConfigCurrentProfileName()), }); export type CommonCommandOptions = z.infer; export function commonOptions(command: Command) { return command - .option("--profile ", "The login profile to use", readAuthConfigCurrentProfileName()) + .option("--profile ", "The login profile to use") .option("-a, --api-url ", "Override the API URL", CLOUD_API_URL) .option( "-l, --log-level ", @@ -30,9 +33,9 @@ export function commonOptions(command: Command) { .option("--skip-telemetry", "Opt-out of sending telemetry"); } -export class SkipLoggingError extends Error {} -export class SkipCommandError extends Error {} -export class OutroCommandError extends SkipCommandError {} +export class SkipLoggingError extends Error { } +export class SkipCommandError extends Error { } +export class OutroCommandError extends SkipCommandError { } export async function handleTelemetry(action: () => Promise) { try { diff --git a/packages/core/src/v3/utils/flattenAttributes.ts b/packages/core/src/v3/utils/flattenAttributes.ts index 83d1a14f2cd..47301467707 100644 --- a/packages/core/src/v3/utils/flattenAttributes.ts +++ b/packages/core/src/v3/utils/flattenAttributes.ts @@ -1,5 +1,14 @@ import { Attributes } from "@opentelemetry/api"; +function escapeKey(key: string): string { + return key.replace(/\./g, "\\."); +} + +function unescapeKey(key: string): string { + return key.replace(/\\\./g, "."); +} + + export const NULL_SENTINEL = "$@null(("; export const CIRCULAR_REFERENCE_SENTINEL = "$@circular(("; @@ -24,7 +33,7 @@ class AttributeFlattener { constructor( private maxAttributeCount?: number, private maxDepth: number = DEFAULT_MAX_DEPTH - ) {} + ) { } get attributes(): Attributes { return this.result; @@ -117,7 +126,8 @@ class AttributeFlattener { 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}`, depth); + this.#processValue(value, `${prefix || "map"}.${escapeKey(keyStr)}`, depth); + } return; } @@ -200,7 +210,9 @@ class AttributeFlattener { break; } - const newPrefix = `${prefix ? `${prefix}.` : ""}${Array.isArray(obj) ? `[${key}]` : key}`; + const escapedKey = Array.isArray(obj) ? `[${key}]` : escapeKey(key); + const newPrefix = `${prefix ? `${prefix}.` : ""}${escapedKey}`; + if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { @@ -278,25 +290,27 @@ export function unflattenAttributes( continue; } - const parts = key.split(".").reduce( + const parts = key.split(/(? { - if (part.startsWith("[") && part.endsWith("]")) { + const unescapedPart = unescapeKey(part); + if (unescapedPart.startsWith("[") && unescapedPart.endsWith("]")) { // Handle array indices more precisely - const match = part.match(/^\[(\d+)\]$/); + const match = unescapedPart.match(/^\[(\d+)\]$/); if (match && match[1]) { acc.push(parseInt(match[1])); } else { // Remove brackets for non-numeric array keys - acc.push(part.slice(1, -1)); + acc.push(unescapedPart.slice(1, -1)); } } else { - acc.push(part); + acc.push(unescapedPart); } return acc; }, [] as (string | number)[] ); + // Skip keys that exceed max depth to prevent memory exhaustion if (parts.length > maxDepth) { continue; diff --git a/packages/core/test/flattenAttributes.test.ts b/packages/core/test/flattenAttributes.test.ts index 28f137deaf9..8446e37849b 100644 --- a/packages/core/test/flattenAttributes.test.ts +++ b/packages/core/test/flattenAttributes.test.ts @@ -1,5 +1,7 @@ +import { describe, it, expect } from "vitest"; import { flattenAttributes, unflattenAttributes } from "../src/v3/utils/flattenAttributes.js"; + describe("flattenAttributes", () => { it("handles number keys correctly", () => { expect(flattenAttributes({ bar: { "25": "foo" } })).toEqual({ "bar.25": "foo" }); @@ -15,6 +17,19 @@ describe("flattenAttributes", () => { expect(unflattenAttributes({ "bar.25": "foo" })).toEqual({ bar: { 25: "foo" } }); }); + it("handles keys with periods correctly", () => { + const obj = { "Key 0.002mm": 31.4 }; + const flattened = flattenAttributes(obj); + expect(flattened).toEqual({ "Key 0\\.002mm": 31.4 }); + expect(unflattenAttributes(flattened)).toEqual(obj); + + const nestedObj = { parent: { "child.key": "value" } }; + const nestedFlattened = flattenAttributes(nestedObj); + expect(nestedFlattened).toEqual({ "parent.child\\.key": "value" }); + expect(unflattenAttributes(nestedFlattened)).toEqual(nestedObj); + }); + + it("handles null correctly", () => { expect(flattenAttributes(null)).toEqual({ "": "$@null((" }); expect(unflattenAttributes({ "": "$@null((" })).toEqual(null); @@ -297,9 +312,9 @@ describe("flattenAttributes", () => { }); it("handles function values correctly", () => { - function namedFunction() {} - const anonymousFunction = function () {}; - const arrowFunction = () => {}; + function namedFunction() { } + const anonymousFunction = function () { }; + const arrowFunction = () => { }; const result = flattenAttributes({ named: namedFunction, @@ -317,7 +332,7 @@ describe("flattenAttributes", () => { it("handles mixed problematic types", () => { const complexObj = { error: new Error("Mixed error"), - func: function testFunc() {}, + func: function testFunc() { }, date: new Date("2023-01-01"), normal: "string", number: 42, @@ -415,10 +430,10 @@ describe("flattenAttributes", () => { it("handles Promise objects correctly", () => { const resolvedPromise = Promise.resolve("value"); const rejectedPromise = Promise.reject(new Error("failed")); - const pendingPromise = new Promise(() => {}); // Never resolves + const pendingPromise = new Promise(() => { }); // Never resolves // Catch the rejection to avoid unhandled promise rejection warnings - rejectedPromise.catch(() => {}); + rejectedPromise.catch(() => { }); const result = flattenAttributes({ resolved: resolvedPromise, @@ -481,7 +496,7 @@ describe("flattenAttributes", () => { it("handles complex mixed object with all special types", () => { const complexObj = { error: new Error("Test error"), - func: function testFunc() {}, + func: function testFunc() { }, date: new Date("2023-01-01"), mySet: new Set([1, 2, 3]), myMap: new Map([["key", "value"]]), @@ -629,6 +644,34 @@ describe("unflattenAttributes", () => { }); }); + it("handles keys with periods correctly (literal keys vs. nested paths)", () => { + const flattened = { + "user.name": "John Doe", + "user\\.email": "john.doe@example.com", + "data.version": "1.0", + "file.name.with\\.dots": "document.pdf", + }; + + const expected = { + user: { + name: "John Doe", + }, + "user.email": "john.doe@example.com", + data: { + version: "1.0", + }, + file: { + name: { + "with.dots": "document.pdf", + }, + }, + }; + + + expect(unflattenAttributes(flattened)).toEqual(expected); + }); + + it("respects maxDepth limit and skips overly deep keys", () => { // Create a flattened object with keys at various depths const flattened = {