Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/ecs/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,9 @@ export const DatadogEcsBaseDefaultProps: DatadogECSBaseProps = {
timeout: Duration.seconds(5),
},
};

/**
* IDs of the global tags warnings
*/
export const GlobalTagsWhitespaceWarningId = "datadog-cdk-constructs-v2:globalTagsWhitespace";
export const AgentTagsCommaWarningId = "datadog-cdk-constructs-v2:agentTagsComma";
2 changes: 1 addition & 1 deletion src/ecs/fargate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | 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. |
Expand Down
5 changes: 4 additions & 1 deletion src/ecs/fargate/datadog-ecs-fargate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { DatadogECSFargateProps, LoggingType } from "./interfaces";
import { copyAwsLogsDriver, mergeFargateProps, validateECSFargateProps } from "./utils";
import {
addCdkConstructVersionTag,
addGlobalTagsWarnings,
configureEcsPolicies,
getSecretApiKey,
isCpuArchitectureArm64,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 = "";
Expand Down
5 changes: 4 additions & 1 deletion src/ecs/fargate/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/ecs/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export interface DatadogECSBaseProps {
readonly environmentVariables?: Record<string, 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.
*/
readonly globalTags?: string;
Expand Down
2 changes: 1 addition & 1 deletion src/ecs/managed-instances/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | 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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion src/ecs/managed-instances/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
53 changes: 52 additions & 1 deletion src/ecs/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
* Copyright 2020-2026 Datadog, Inc.
*/

import { 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";
import * as ssm from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";
import log from "loglevel";
import { AgentTagsCommaWarningId, GlobalTagsWhitespaceWarningId } from "./constants";
import { DatadogECSBaseProps } from "./interfaces";
import * as versionJson from "../../version.json";
import { invalidSiteError, siteList, TagKeys } from "../constants";
Expand Down Expand Up @@ -111,3 +112,53 @@ export function addCdkConstructVersionTag(task: ecs.TaskDefinition): void {
includeResourceTypes: ["AWS::ECS::TaskDefinition"],
});
}

/**
* 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the Agent also accepts DD_TAGS as a JSON array, so globalTags: '["env:prod","team:pay"]' used to give correct Agent tags. After this change it becomes ["env:prod" "team:pay"] and splits into broken tags. Probably rare, but could values starting with [ pass through unchanged, or get a warning?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I didn't realize this was also an accepted format. Updated to pass through unchanged in bdae3bc

if (isJsonArrayTags(globalTags)) {
return globalTags;
}
if (Token.isUnresolved(globalTags)) {
return Fn.join(" ", Fn.split(",", globalTags));
}
return globalTags.replace(/,/g, " ");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If globalTags comes from a token whose resolved value has commas (e.g. a CfnParameter or SSM parameter set to env:prod,team:pay), the replace sees no commas at synth, so the Agent still gets the comma form at deploy and nothing warns. Could toAgentTags use Fn.join(" ", Fn.split(",", globalTags)) when Token.isUnresolved(globalTags), so CloudFormation does the conversion? The current token test only covers a token inside literal tags.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, addressed in bdae3bc

}

/**
* 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) {
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.",
);
}
return;
}
if (isJsonArrayTags(globalTags)) {
return;
}
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;
}
}
}
6 changes: 2 additions & 4 deletions src/sample/ecs_fargate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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", {
Expand Down
2 changes: 1 addition & 1 deletion test/ecs/fargate/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
99 changes: 98 additions & 1 deletion test/ecs/fargate/logging.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -446,4 +447,100 @@ 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("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,
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}\\]`),
);
});
});
});
15 changes: 15 additions & 0 deletions test/ecs/managed-instances/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading