From a6db3837af77e0960a9c89e5ddb2b23943ec2d01 Mon Sep 17 00:00:00 2001 From: Tal Usvyatsky Date: Tue, 20 Oct 2026 17:06:02 -0400 Subject: [PATCH 1/3] fix(ecs): convert comma-separated globalTags for Agent DD_TAGS (#742) - Convert comma-separated globalTags to whitespace-separated DD_TAGS for the Agent - Keep comma-separated tags for the Fluent Bit log router - Add synth warnings for whitespace/token in globalTags and commas in DD_TAGS - Update docs, sample, and tests Rationale: The Agent reads DD_TAGS as whitespace-separated, so comma-separated globalTags were parsed incorrectly. Ticket: SVLS-9885 Co-Authored-By: Claude Opus 5.5 (1M context) This commit made by [/dd:git:commit:quick](https://github.com/DataDog/claude-marketplace/tree/main/dd/commands/git/commit/quick.md) --- src/ecs/constants.ts | 7 ++ src/ecs/fargate/README.md | 2 +- src/ecs/fargate/datadog-ecs-fargate.ts | 5 +- src/ecs/fargate/environment.ts | 5 +- src/ecs/interfaces.ts | 2 + src/ecs/managed-instances/README.md | 2 +- .../datadog-ecs-managed-instances.ts | 3 +- src/ecs/managed-instances/environment.ts | 5 +- src/ecs/utils.ts | 41 +++++++++- src/sample/ecs_fargate/index.ts | 6 +- test/ecs/fargate/agent.spec.ts | 2 +- test/ecs/fargate/logging.spec.ts | 76 ++++++++++++++++++- test/ecs/managed-instances/agent.spec.ts | 15 ++++ test/ecs/utils.spec.ts | 62 +++++++++++++++ 14 files changed, 220 insertions(+), 13 deletions(-) diff --git a/src/ecs/constants.ts b/src/ecs/constants.ts index 34b182670..f22e8bd21 100644 --- a/src/ecs/constants.ts +++ b/src/ecs/constants.ts @@ -44,3 +44,10 @@ export const DatadogEcsBaseDefaultProps: DatadogECSBaseProps = { timeout: Duration.seconds(5), }, }; + +/** + * IDs of the global tags warnings + */ +export const GlobalTagsWhitespaceWarningId = "datadog-cdk-constructs-v2:globalTagsWhitespace"; +export const GlobalTagsTokenWarningId = "datadog-cdk-constructs-v2:globalTagsToken"; +export const AgentTagsCommaWarningId = "datadog-cdk-constructs-v2:agentTagsComma"; diff --git a/src/ecs/fargate/README.md b/src/ecs/fargate/README.md index 9831fc211..7b02f0014 100644 --- a/src/ecs/fargate/README.md +++ b/src/ecs/fargate/README.md @@ -160,7 +160,7 @@ For more general information, reference the [Datadog ECS Fargate Docs](https://d | `site` | `string` | The Datadog site to send data to. | | `clusterName` | `string` | The cluster name to use for tagging. | | `environmentVariables` | `Record` | Datadog Agent environment variables. Used to customize your Datadog Agent configuration. | -| `globalTags` | `string` | Global tags to apply to all data sent by the Agent. Overrides any `DD_TAGS` values in `environmentVariables`. | +| `globalTags` | `string` | Global tags to apply to all data sent by the Agent and the Fluent Bit log router. Use a comma-separated list of `key:value` pairs, for example `env:prod,team:payments`. Tag values can't contain commas or whitespace. Overrides any `DD_TAGS` values in `environmentVariables`. | | `checksCardinality` | `Cardinality` | The tag cardinality for checks run by the Agent. | | `dogstatsd` | `DogstatsdFeatureConfig` | DogStatsD feature configuration. | | `apm` | `APMFeatureConfig` | APM feature configuration. | diff --git a/src/ecs/fargate/datadog-ecs-fargate.ts b/src/ecs/fargate/datadog-ecs-fargate.ts index e41c47798..cb65e40d0 100644 --- a/src/ecs/fargate/datadog-ecs-fargate.ts +++ b/src/ecs/fargate/datadog-ecs-fargate.ts @@ -42,6 +42,7 @@ import { DatadogECSFargateProps, LoggingType } from "./interfaces"; import { copyAwsLogsDriver, mergeFargateProps, validateECSFargateProps } from "./utils"; import { addCdkConstructVersionTag, + addGlobalTagsWarnings, configureEcsPolicies, getSecretApiKey, isCpuArchitectureArm64, @@ -107,6 +108,7 @@ export class DatadogECSFargateTaskDefinition extends ecs.FargateTaskDefinition { this.datadogProps = this.getCompleteProps(props, datadogProps); validateECSBaseProps(this.datadogProps); validateECSFargateProps(this.datadogProps); + addGlobalTagsWarnings(this, this.datadogProps); this.datadogContainer = this.createAgentContainer(this.datadogProps); @@ -483,7 +485,8 @@ export class DatadogECSFargateTaskDefinition extends ecs.FargateTaskDefinition { const logServiceName = name ?? fluentbitLogDriverConfig.serviceName; const logSourceName = name ?? fluentbitLogDriverConfig.sourceName; - let logTags = this.datadogProps.envVarManager.retrieve("DD_TAGS"); + // Fluent Bit expects comma-separated tags, so use globalTags instead of the converted DD_TAGS + let logTags = this.datadogProps.globalTags ?? this.datadogProps.envVarManager.retrieve("DD_TAGS"); if (this.datadogProps.clusterName !== undefined) { if (logTags === undefined) { logTags = ""; diff --git a/src/ecs/fargate/environment.ts b/src/ecs/fargate/environment.ts index d5905d0ba..f4e55d0d4 100644 --- a/src/ecs/fargate/environment.ts +++ b/src/ecs/fargate/environment.ts @@ -11,6 +11,7 @@ import { FargateDefaultEnvVars } from "./constants"; import { DatadogECSFargateProps } from "./interfaces"; import * as versionJson from "../../../version.json"; import { EnvVarManager } from "../environment"; +import { toAgentTags } from "../utils"; export class FargateEnvVarManager extends EnvVarManager { constructor(props: DatadogECSFargateProps) { @@ -34,7 +35,9 @@ export class FargateEnvVarManager extends EnvVarManager { "and the props. The environment variable will be overwritten.", ); } - this.add("DD_TAGS", props.globalTags); + if (props.globalTags !== undefined) { + this.add("DD_TAGS", toAgentTags(props.globalTags)); + } this.add("DD_CLUSTER_NAME", props.clusterName); if (props.dogstatsd!.isOriginDetectionEnabled) { diff --git a/src/ecs/interfaces.ts b/src/ecs/interfaces.ts index c96768042..d78143769 100644 --- a/src/ecs/interfaces.ts +++ b/src/ecs/interfaces.ts @@ -89,6 +89,8 @@ export interface DatadogECSBaseProps { readonly environmentVariables?: Record; /** * Global tags to apply to all data sent by the Agent. + * Use a comma-separated list of key:value pairs, for example "env:prod,team:payments". + * Tag values can't contain commas or whitespace. * Overrides any DD_TAGS values in environmentVariables. */ readonly globalTags?: string; diff --git a/src/ecs/managed-instances/README.md b/src/ecs/managed-instances/README.md index e50f15716..733832c44 100644 --- a/src/ecs/managed-instances/README.md +++ b/src/ecs/managed-instances/README.md @@ -132,7 +132,7 @@ For more general information, reference the [Datadog ECS Managed Instances Docs] | `site` | `string` | The Datadog site to send data to. | | `clusterName` | `string` | The cluster name to use for tagging. Sets `DD_CLUSTER_NAME`. | | `environmentVariables` | `Record` | Datadog Agent environment variables. Used to customize your Datadog Agent configuration. Variables the construct sets from its own properties (for example `DD_APM_ENABLED`, `DD_USE_DOGSTATSD`, `DD_TAGS`) take precedence over the same variable here. | -| `globalTags` | `string` | Global tags to apply to all data sent by the Agent. Overrides any `DD_TAGS` values in `environmentVariables`. | +| `globalTags` | `string` | Global tags to apply to all data sent by the Agent. Use a comma-separated list of `key:value` pairs, for example `env:prod,team:payments`. Tag values can't contain commas or whitespace. Overrides any `DD_TAGS` values in `environmentVariables`. | | `checksCardinality` | `Cardinality` | The tag cardinality for checks run by the Agent. | | `dogstatsd` | `DogstatsdFeatureConfig` | DogStatsD feature configuration. | | `apm` | `APMFeatureConfig` | APM feature configuration. | diff --git a/src/ecs/managed-instances/datadog-ecs-managed-instances.ts b/src/ecs/managed-instances/datadog-ecs-managed-instances.ts index d2c3505a1..3054e6230 100644 --- a/src/ecs/managed-instances/datadog-ecs-managed-instances.ts +++ b/src/ecs/managed-instances/datadog-ecs-managed-instances.ts @@ -23,7 +23,7 @@ import { DatadogECSManagedInstancesInternalProps } from "./internal.interfaces"; import { mergeManagedInstancesProps, validateECSManagedInstancesProps } from "./utils"; import * as versionJson from "../../../version.json"; import { TagKeys } from "../../constants"; -import { getSecretApiKey, validateECSBaseProps } from "../utils"; +import { addGlobalTagsWarnings, getSecretApiKey, validateECSBaseProps } from "../utils"; type VolumeProperty = ecs.CfnDaemonTaskDefinition.VolumeProperty; type MountPointProperty = ecs.CfnDaemonTaskDefinition.MountPointProperty; @@ -137,6 +137,7 @@ export class DatadogECSManagedInstancesDaemonTaskDefinition extends Construct { this.datadogProps = this.getCompleteProps(datadogProps); validateECSBaseProps(this.datadogProps); validateECSManagedInstancesProps(this.datadogProps); + addGlobalTagsWarnings(this, this.datadogProps); this.executionRole = this.resolveExecutionRole(); this.taskRole = this.resolveTaskRole(); diff --git a/src/ecs/managed-instances/environment.ts b/src/ecs/managed-instances/environment.ts index 3efcdec19..bd6e9b9f7 100644 --- a/src/ecs/managed-instances/environment.ts +++ b/src/ecs/managed-instances/environment.ts @@ -11,6 +11,7 @@ import { ContainerCriSocketPath, ManagedInstancesDefaultEnvVars } from "./consta import { DatadogECSManagedInstancesProps } from "./interfaces"; import * as versionJson from "../../../version.json"; import { EnvVarManager } from "../environment"; +import { toAgentTags } from "../utils"; export class ManagedInstancesEnvVarManager extends EnvVarManager { constructor(props: DatadogECSManagedInstancesProps, isSocketRequired: boolean, isProtocolRequired: boolean) { @@ -35,7 +36,9 @@ export class ManagedInstancesEnvVarManager extends EnvVarManager { "and the props. The environment variable will be overwritten.", ); } - this.add("DD_TAGS", props.globalTags); + if (props.globalTags !== undefined) { + this.add("DD_TAGS", toAgentTags(props.globalTags)); + } this.add("DD_CLUSTER_NAME", props.clusterName); if (props.dogstatsd!.isEnabled && props.dogstatsd!.isOriginDetectionEnabled) { diff --git a/src/ecs/utils.ts b/src/ecs/utils.ts index 0bc33816a..df21f7015 100644 --- a/src/ecs/utils.ts +++ b/src/ecs/utils.ts @@ -6,13 +6,14 @@ * Copyright 2020-2026 Datadog, Inc. */ -import { Tags } from "aws-cdk-lib"; +import { Annotations, Tags, Token } from "aws-cdk-lib"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as iam from "aws-cdk-lib/aws-iam"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import * as ssm from "aws-cdk-lib/aws-ssm"; import { Construct } from "constructs"; import log from "loglevel"; +import { AgentTagsCommaWarningId, GlobalTagsTokenWarningId, GlobalTagsWhitespaceWarningId } from "./constants"; import { DatadogECSBaseProps } from "./interfaces"; import * as versionJson from "../../version.json"; import { invalidSiteError, siteList, TagKeys } from "../constants"; @@ -111,3 +112,41 @@ export function addCdkConstructVersionTag(task: ecs.TaskDefinition): void { includeResourceTypes: ["AWS::ECS::TaskDefinition"], }); } + +/** + * Converts comma-separated global tags to the whitespace-separated format that the Agent reads from DD_TAGS. + */ +export function toAgentTags(globalTags: string): string { + return globalTags.replace(/,/g, " "); +} + +/** + * Warns about global tags and DD_TAGS values that the construct can't format correctly for the Agent. + */ +export function addGlobalTagsWarnings(scope: Construct, props: DatadogECSBaseProps): void { + const globalTags = props.globalTags; + if (globalTags === undefined) { + if (props.environmentVariables?.DD_TAGS?.includes(",")) { + Annotations.of(scope).addWarningV2( + AgentTagsCommaWarningId, + "DD_TAGS in `environmentVariables` contains commas, but the Datadog Agent separates tags with whitespace. Set `globalTags` instead, which accepts comma-separated tags.", + ); + } + return; + } + if (Token.isUnresolved(globalTags)) { + Annotations.of(scope).addWarningV2( + GlobalTagsTokenWarningId, + "`globalTags` contains a token. Commas inside the resolved value of the token aren't converted to the whitespace-separated format of DD_TAGS.", + ); + } + for (const tag of globalTags.split(",")) { + if (/\S\s+\S/.test(tag)) { + Annotations.of(scope).addWarningV2( + GlobalTagsWhitespaceWarningId, + `\`globalTags\` item "${tag.trim()}" contains whitespace. Separate tags with commas. Tag values can't contain whitespace.`, + ); + return; + } + } +} diff --git a/src/sample/ecs_fargate/index.ts b/src/sample/ecs_fargate/index.ts index 3c4713f23..637a36dea 100644 --- a/src/sample/ecs_fargate/index.ts +++ b/src/sample/ecs_fargate/index.ts @@ -48,9 +48,7 @@ export class ExampleStack extends Stack { apiKeySecret: secret, clusterName: cluster.clusterName, isDatadogDependencyEnabled: false, - environmentVariables: { - DD_TAGS: "team:cont-p, owner:container-monitoring", - }, + globalTags: "team:cont-p,owner:container-monitoring", dogstatsd: { isEnabled: true, }, @@ -131,7 +129,7 @@ export class ExampleStack extends Stack { }, { apiKeySecret: secret, - globalTags: "team:cont-p, owner:container-monitoring", + globalTags: "team:cont-p,owner:container-monitoring", }, ); task.addContainer("DummyDogstatsd", { diff --git a/test/ecs/fargate/agent.spec.ts b/test/ecs/fargate/agent.spec.ts index 9e14283ce..d6170ec6f 100644 --- a/test/ecs/fargate/agent.spec.ts +++ b/test/ecs/fargate/agent.spec.ts @@ -97,7 +97,7 @@ describe("DatadogECSFargateTaskDefinition", () => { Environment: Match.arrayWith([ Match.objectEquals({ Name: "DD_TAGS", - Value: "tag1:value1,tag2:value2", + Value: "tag1:value1 tag2:value2", }), Match.objectEquals({ Name: "DD_CUSTOM_ENV_VAR", diff --git a/test/ecs/fargate/logging.spec.ts b/test/ecs/fargate/logging.spec.ts index 0a5634222..0a46bffbd 100644 --- a/test/ecs/fargate/logging.spec.ts +++ b/test/ecs/fargate/logging.spec.ts @@ -1,8 +1,9 @@ import * as cdk from "aws-cdk-lib"; -import { Template, Match } from "aws-cdk-lib/assertions"; +import { Annotations, Template, Match } from "aws-cdk-lib/assertions"; import * as ecs from "aws-cdk-lib/aws-ecs"; import { Construct } from "constructs"; import * as ecsDatadog from "../../../src/ecs"; +import { AgentTagsCommaWarningId, GlobalTagsWhitespaceWarningId } from "../../../src/ecs/constants"; import { ParseJsonFirelensConfigFileType } from "../../../src/ecs/fargate/constants"; describe("DatadogECSFargateLogging", () => { @@ -446,4 +447,77 @@ describe("DatadogECSFargateLogging", () => { ]), }); }); + + describe("global tags", () => { + const agentContainer = (template: Template) => { + const [taskDefinition] = Object.values(template.findResources("AWS::ECS::TaskDefinition")); + return taskDefinition.Properties.ContainerDefinitions.find((c: any) => c.Name === "datadog-agent"); + }; + const agentTags = (template: Template) => + agentContainer(template).Environment.find((e: any) => e.Name === "DD_TAGS")?.Value; + const logTags = (template: Template) => agentContainer(template).LogConfiguration.Options.dd_tags; + + it.each([ + ["env:prod,team:payments", "env:prod team:payments", "env:prod,team:payments, ecs_cluster_name:test-cluster"], + ["env:prod, team:payments", "env:prod team:payments", "env:prod, team:payments, ecs_cluster_name:test-cluster"], + ["env:prod", "env:prod", "env:prod, ecs_cluster_name:test-cluster"], + ])("sends %p to the Agent as %p and to the log router as %p", (globalTags, expectedAgentTags, expectedLogTags) => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + clusterName: "test-cluster", + globalTags, + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual(expectedAgentTags); + expect(logTags(template)).toEqual(expectedLogTags); + }); + + it("sends global tags to the log router unchanged when clusterName is not set", () => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + globalTags: "env:prod,team:payments", + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual("env:prod team:payments"); + expect(logTags(template)).toEqual("env:prod,team:payments"); + }); + + it("uses global tags instead of DD_TAGS in environmentVariables for the Agent and the log router", () => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + globalTags: "env:prod,team:payments", + environmentVariables: { DD_TAGS: "old:value" }, + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual("env:prod team:payments"); + expect(logTags(template)).toEqual("env:prod,team:payments"); + }); + + it("sends DD_TAGS in environmentVariables unchanged when global tags are not set", () => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + environmentVariables: { DD_TAGS: "env:prod,team:payments" }, + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual("env:prod,team:payments"); + expect(logTags(template)).toEqual("env:prod,team:payments"); + Annotations.fromStack(stack).hasWarning("*", Match.stringLikeRegexp(`\\[ack: ${AgentTagsCommaWarningId}\\]`)); + }); + + it("warns when a global tags item contains whitespace", () => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + globalTags: "env:prod team:payments", + }); + + Annotations.fromStack(stack).hasWarning( + "*", + Match.stringLikeRegexp(`\\[ack: ${GlobalTagsWhitespaceWarningId}\\]`), + ); + }); + }); }); diff --git a/test/ecs/managed-instances/agent.spec.ts b/test/ecs/managed-instances/agent.spec.ts index c1d471fd6..5ee7a9193 100644 --- a/test/ecs/managed-instances/agent.spec.ts +++ b/test/ecs/managed-instances/agent.spec.ts @@ -206,6 +206,21 @@ describe("DatadogECSManagedInstancesDaemonTaskDefinition - agent container", () }); }); + it("converts comma-separated global tags to whitespace-separated DD_TAGS", () => { + datadogProps = { ...datadogProps, globalTags: "env:prod,team:payments" }; + new ecsDatadog.DatadogECSManagedInstancesDaemonTaskDefinition(scope, id, datadogProps); + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::ECS::DaemonTaskDefinition", { + ContainerDefinitions: Match.arrayWith([ + Match.objectLike({ + Name: "datadog-agent", + Environment: Match.arrayWith([Match.objectEquals({ Name: "DD_TAGS", Value: "env:prod team:payments" })]), + }), + ]), + }); + }); + it("does not set DD_CLUSTER_NAME when clusterName is not provided", () => { new ecsDatadog.DatadogECSManagedInstancesDaemonTaskDefinition(scope, id, datadogProps); const template = Template.fromStack(stack); diff --git a/test/ecs/utils.spec.ts b/test/ecs/utils.spec.ts index a8e1e9d2f..2e8186295 100644 --- a/test/ecs/utils.spec.ts +++ b/test/ecs/utils.spec.ts @@ -1,12 +1,20 @@ import * as cdk from "aws-cdk-lib"; +import { Annotations, Match } from "aws-cdk-lib/assertions"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import { Construct } from "constructs"; +import { + AgentTagsCommaWarningId, + GlobalTagsTokenWarningId, + GlobalTagsWhitespaceWarningId, +} from "../../src/ecs/constants"; import { validateECSBaseProps, isOperatingSystemLinux, getSecretApiKey, addCdkConstructVersionTag, + addGlobalTagsWarnings, + toAgentTags, } from "../../src/ecs/utils"; describe("utils", () => { @@ -189,4 +197,58 @@ describe("utils", () => { }); }); }); + + describe("toAgentTags", () => { + it.each([ + ["env:prod,team:payments", "env:prod team:payments"], + ["env:prod, team:payments", "env:prod team:payments"], + ["env:prod", "env:prod"], + ["env:prod,", "env:prod "], + ["", ""], + ])("converts %p to %p", (globalTags, expected) => { + expect(toAgentTags(globalTags)).toEqual(expected); + }); + + it("keeps tokens in the converted tags", () => { + const param = new cdk.CfnParameter(stack, "Env"); + const agentTags = toAgentTags(`env:${param.valueAsString},team:payments`); + expect(stack.resolve(agentTags)).toEqual({ "Fn::Join": ["", ["env:", { Ref: "Env" }, " team:payments"]] }); + }); + }); + + describe("addGlobalTagsWarnings", () => { + const warning = (id: string) => Match.stringLikeRegexp(`\\[ack: ${id}\\]`); + + it.each(["env:prod,team:payments", "env:prod, team:payments", "env:prod"])( + "does not warn for comma-separated global tags %p", + (globalTags) => { + addGlobalTagsWarnings(scope, { globalTags, environmentVariables: { DD_TAGS: "a:1,b:2" } }); + Annotations.fromStack(stack).hasNoWarning("*", Match.anyValue()); + }, + ); + + it.each(["env:prod team:payments", "env:prod,team:Data Platform"])( + "warns when a global tags item in %p contains whitespace", + (globalTags) => { + addGlobalTagsWarnings(scope, { globalTags }); + Annotations.fromStack(stack).hasWarning("*", warning(GlobalTagsWhitespaceWarningId)); + }, + ); + + it("warns when global tags contain a token", () => { + const param = new cdk.CfnParameter(stack, "Tags"); + addGlobalTagsWarnings(scope, { globalTags: param.valueAsString }); + Annotations.fromStack(stack).hasWarning("*", warning(GlobalTagsTokenWarningId)); + }); + + it("warns when DD_TAGS in environmentVariables contains commas and global tags are not set", () => { + addGlobalTagsWarnings(scope, { environmentVariables: { DD_TAGS: "env:prod,team:payments" } }); + Annotations.fromStack(stack).hasWarning("*", warning(AgentTagsCommaWarningId)); + }); + + it("does not warn when DD_TAGS in environmentVariables is whitespace-separated", () => { + addGlobalTagsWarnings(scope, { environmentVariables: { DD_TAGS: "env:prod team:payments" } }); + Annotations.fromStack(stack).hasNoWarning("*", Match.anyValue()); + }); + }); }); From e1039513e46e926bafd6f261d3a9f3e3a1fc7979 Mon Sep 17 00:00:00 2001 From: Tal Usvyatsky Date: Tue, 27 Oct 2026 09:50:45 -0400 Subject: [PATCH 2/3] fix(ecs): only warn about literal globalTags values Remove the token warning. A token inside globalTags converts correctly when its value is one tag, so the warning fired on safe code. The construct now warns only about literal values it can check at synth time. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ecs/constants.ts | 1 - src/ecs/utils.ts | 10 ++-------- test/ecs/utils.spec.ts | 15 +++++++-------- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/ecs/constants.ts b/src/ecs/constants.ts index f22e8bd21..5f224ee06 100644 --- a/src/ecs/constants.ts +++ b/src/ecs/constants.ts @@ -49,5 +49,4 @@ export const DatadogEcsBaseDefaultProps: DatadogECSBaseProps = { * IDs of the global tags warnings */ export const GlobalTagsWhitespaceWarningId = "datadog-cdk-constructs-v2:globalTagsWhitespace"; -export const GlobalTagsTokenWarningId = "datadog-cdk-constructs-v2:globalTagsToken"; export const AgentTagsCommaWarningId = "datadog-cdk-constructs-v2:agentTagsComma"; diff --git a/src/ecs/utils.ts b/src/ecs/utils.ts index df21f7015..d1a71b811 100644 --- a/src/ecs/utils.ts +++ b/src/ecs/utils.ts @@ -6,14 +6,14 @@ * Copyright 2020-2026 Datadog, Inc. */ -import { Annotations, Tags, Token } from "aws-cdk-lib"; +import { Annotations, Tags } from "aws-cdk-lib"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as iam from "aws-cdk-lib/aws-iam"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import * as ssm from "aws-cdk-lib/aws-ssm"; import { Construct } from "constructs"; import log from "loglevel"; -import { AgentTagsCommaWarningId, GlobalTagsTokenWarningId, GlobalTagsWhitespaceWarningId } from "./constants"; +import { AgentTagsCommaWarningId, GlobalTagsWhitespaceWarningId } from "./constants"; import { DatadogECSBaseProps } from "./interfaces"; import * as versionJson from "../../version.json"; import { invalidSiteError, siteList, TagKeys } from "../constants"; @@ -134,12 +134,6 @@ export function addGlobalTagsWarnings(scope: Construct, props: DatadogECSBasePro } return; } - if (Token.isUnresolved(globalTags)) { - Annotations.of(scope).addWarningV2( - GlobalTagsTokenWarningId, - "`globalTags` contains a token. Commas inside the resolved value of the token aren't converted to the whitespace-separated format of DD_TAGS.", - ); - } for (const tag of globalTags.split(",")) { if (/\S\s+\S/.test(tag)) { Annotations.of(scope).addWarningV2( diff --git a/test/ecs/utils.spec.ts b/test/ecs/utils.spec.ts index 2e8186295..ce8ccd599 100644 --- a/test/ecs/utils.spec.ts +++ b/test/ecs/utils.spec.ts @@ -3,11 +3,7 @@ import { Annotations, Match } from "aws-cdk-lib/assertions"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import { Construct } from "constructs"; -import { - AgentTagsCommaWarningId, - GlobalTagsTokenWarningId, - GlobalTagsWhitespaceWarningId, -} from "../../src/ecs/constants"; +import { AgentTagsCommaWarningId, GlobalTagsWhitespaceWarningId } from "../../src/ecs/constants"; import { validateECSBaseProps, isOperatingSystemLinux, @@ -235,10 +231,13 @@ describe("utils", () => { }, ); - it("warns when global tags contain a token", () => { + it.each([ + ["a token", (param: cdk.CfnParameter) => param.valueAsString], + ["a token inside literal tags", (param: cdk.CfnParameter) => `env:${param.valueAsString},team:payments`], + ])("does not warn when global tags contain %s", (_, globalTags) => { const param = new cdk.CfnParameter(stack, "Tags"); - addGlobalTagsWarnings(scope, { globalTags: param.valueAsString }); - Annotations.fromStack(stack).hasWarning("*", warning(GlobalTagsTokenWarningId)); + addGlobalTagsWarnings(scope, { globalTags: globalTags(param) }); + Annotations.fromStack(stack).hasNoWarning("*", Match.anyValue()); }); it("warns when DD_TAGS in environmentVariables contains commas and global tags are not set", () => { From bdae3bc6c01dfb010eab4b58eba48bea88700813 Mon Sep 17 00:00:00 2001 From: Tal Usvyatsky Date: Tue, 27 Oct 2026 16:22:41 -0400 Subject: [PATCH 3/3] fix(ecs): convert token globalTags at deploy and pass through JSON arrays - Convert globalTags that contain a token with Fn::Split and Fn::Join, so CloudFormation converts commas in the resolved value for DD_TAGS - Pass a JSON array of tags through unchanged, because the Agent also accepts DD_TAGS as a JSON array - Skip the tag warnings for JSON arrays Co-Authored-By: Claude Opus 5.5 --- src/ecs/utils.ts | 22 ++++++++++++++++++++-- test/ecs/fargate/logging.spec.ts | 23 +++++++++++++++++++++++ test/ecs/utils.spec.ts | 22 ++++++++++++++++++++-- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/ecs/utils.ts b/src/ecs/utils.ts index d1a71b811..232428d72 100644 --- a/src/ecs/utils.ts +++ b/src/ecs/utils.ts @@ -6,7 +6,7 @@ * Copyright 2020-2026 Datadog, Inc. */ -import { Annotations, Tags } from "aws-cdk-lib"; +import { Annotations, Fn, Tags, Token } from "aws-cdk-lib"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as iam from "aws-cdk-lib/aws-iam"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; @@ -113,10 +113,24 @@ export function addCdkConstructVersionTag(task: ecs.TaskDefinition): void { }); } +/** + * Returns true for a JSON array of tags, which the Agent also accepts in DD_TAGS. + */ +function isJsonArrayTags(tags: string): boolean { + return tags.trimStart().startsWith("["); +} + /** * Converts comma-separated global tags to the whitespace-separated format that the Agent reads from DD_TAGS. + * A JSON array of tags is passed through unchanged. For a token, CloudFormation does the conversion at deploy time. */ export function toAgentTags(globalTags: string): string { + if (isJsonArrayTags(globalTags)) { + return globalTags; + } + if (Token.isUnresolved(globalTags)) { + return Fn.join(" ", Fn.split(",", globalTags)); + } return globalTags.replace(/,/g, " "); } @@ -126,7 +140,8 @@ export function toAgentTags(globalTags: string): string { export function addGlobalTagsWarnings(scope: Construct, props: DatadogECSBaseProps): void { const globalTags = props.globalTags; if (globalTags === undefined) { - if (props.environmentVariables?.DD_TAGS?.includes(",")) { + const agentTags = props.environmentVariables?.DD_TAGS; + if (agentTags !== undefined && agentTags.includes(",") && !isJsonArrayTags(agentTags)) { Annotations.of(scope).addWarningV2( AgentTagsCommaWarningId, "DD_TAGS in `environmentVariables` contains commas, but the Datadog Agent separates tags with whitespace. Set `globalTags` instead, which accepts comma-separated tags.", @@ -134,6 +149,9 @@ export function addGlobalTagsWarnings(scope: Construct, props: DatadogECSBasePro } return; } + if (isJsonArrayTags(globalTags)) { + return; + } for (const tag of globalTags.split(",")) { if (/\S\s+\S/.test(tag)) { Annotations.of(scope).addWarningV2( diff --git a/test/ecs/fargate/logging.spec.ts b/test/ecs/fargate/logging.spec.ts index 0a46bffbd..349866b10 100644 --- a/test/ecs/fargate/logging.spec.ts +++ b/test/ecs/fargate/logging.spec.ts @@ -484,6 +484,29 @@ describe("DatadogECSFargateLogging", () => { expect(logTags(template)).toEqual("env:prod,team:payments"); }); + it("passes a JSON array of global tags to the Agent and the log router unchanged", () => { + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + globalTags: '["env:prod","team:payments"]', + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual('["env:prod","team:payments"]'); + expect(logTags(template)).toEqual('["env:prod","team:payments"]'); + }); + + it("converts token global tags for the Agent with CloudFormation functions and keeps them for the log router", () => { + const param = new cdk.CfnParameter(stack, "Tags"); + new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { + ...datadogProps, + globalTags: param.valueAsString, + }); + const template = Template.fromStack(stack); + + expect(agentTags(template)).toEqual({ "Fn::Join": [" ", { "Fn::Split": [",", { Ref: "Tags" }] }] }); + expect(logTags(template)).toEqual({ Ref: "Tags" }); + }); + it("uses global tags instead of DD_TAGS in environmentVariables for the Agent and the log router", () => { new ecsDatadog.DatadogECSFargateTaskDefinition(scope, id, props, { ...datadogProps, diff --git a/test/ecs/utils.spec.ts b/test/ecs/utils.spec.ts index ce8ccd599..0cb163962 100644 --- a/test/ecs/utils.spec.ts +++ b/test/ecs/utils.spec.ts @@ -201,14 +201,24 @@ describe("utils", () => { ["env:prod", "env:prod"], ["env:prod,", "env:prod "], ["", ""], + ['["env:prod","team:payments"]', '["env:prod","team:payments"]'], + [' ["env:prod", "team:payments"]', ' ["env:prod", "team:payments"]'], ])("converts %p to %p", (globalTags, expected) => { expect(toAgentTags(globalTags)).toEqual(expected); }); - it("keeps tokens in the converted tags", () => { + it("converts a token with CloudFormation functions", () => { + const param = new cdk.CfnParameter(stack, "Tags"); + const agentTags = toAgentTags(param.valueAsString); + expect(stack.resolve(agentTags)).toEqual({ "Fn::Join": [" ", { "Fn::Split": [",", { Ref: "Tags" }] }] }); + }); + + it("converts a token inside literal tags with CloudFormation functions", () => { const param = new cdk.CfnParameter(stack, "Env"); const agentTags = toAgentTags(`env:${param.valueAsString},team:payments`); - expect(stack.resolve(agentTags)).toEqual({ "Fn::Join": ["", ["env:", { Ref: "Env" }, " team:payments"]] }); + expect(stack.resolve(agentTags)).toEqual({ + "Fn::Join": [" ", { "Fn::Split": [",", { "Fn::Join": ["", ["env:", { Ref: "Env" }, ",team:payments"]] }] }], + }); }); }); @@ -245,6 +255,14 @@ describe("utils", () => { Annotations.fromStack(stack).hasWarning("*", warning(AgentTagsCommaWarningId)); }); + it.each([ + [{ globalTags: '["env:prod", "team:Data Platform"]' }], + [{ environmentVariables: { DD_TAGS: '["env:prod","team:payments"]' } }], + ])("does not warn for a JSON array of tags in %p", (props) => { + addGlobalTagsWarnings(scope, props); + Annotations.fromStack(stack).hasNoWarning("*", Match.anyValue()); + }); + it("does not warn when DD_TAGS in environmentVariables is whitespace-separated", () => { addGlobalTagsWarnings(scope, { environmentVariables: { DD_TAGS: "env:prod team:payments" } }); Annotations.fromStack(stack).hasNoWarning("*", Match.anyValue());