Skip to content

Commit 4b0bbfc

Browse files
committed
feat(serviceevents): resolve aws.local.environment in the SDK to align with CloudWatch agent
ServiceEvents and Dynamic Instrumentation need aws.local.environment, which today only the CloudWatch agent sets. This adds an SDK-side resolver that computes the same value the agent's awsapplicationsignals resolver would, from the OTel Resource, with no dependency on the agent process: - EnvironmentResolver: mirror the agent's precedence — explicit deployment.environment[.name] -> eks/k8s:<cluster>/<namespace> -> ecs:<cluster> -> ec2:<asg>/ec2:default -> generic:default (never empty, matching the agent's generic resolver off-platform). - Ec2AutoScalingGroupFetcher: read the ASG name from IMDS instance tags (the stock OTel EC2 detector omits it), invoked lazily only on the EC2 branch and memoized process-wide. - DI: resolve aws.local.environment for the instrumentation-config lookup key from the autoconfigured resource. Unit tests included for the resolver, ASG fetcher, and DI config.
1 parent 6c2c18c commit 4b0bbfc

9 files changed

Lines changed: 765 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ If your change does not need a CHANGELOG entry, add the "skip changelog" label t
1313

1414
## Unreleased
1515

16+
- feat(serviceevents): resolve `aws.local.environment` in the SDK (eks/k8s/ecs/ec2/generic) to align
17+
ServiceEvents & Dynamic Instrumentation with the CloudWatch agent, with a custom EC2 ASG fetcher
18+
([#1417](https://github.com/aws-observability/aws-otel-java-instrumentation/pull/1417))
1619
- fix(serviceevents): gate incident trace correlation on the SAMPLED flag
1720
- fix: remove EOL AWS SDK v1 dependency for ARN parsing
1821
([#1401](https://github.com/aws-observability/aws-otel-java-instrumentation/pull/1401))

awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/dynamicInstrumentation/DynamicInstrumentationManager.java

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import software.amazon.opentelemetry.javaagent.providers.dynamicInstrumentation.output.DISerializerImpl;
4444
import software.amazon.opentelemetry.javaagent.providers.dynamicInstrumentation.output.DISnapshotCollector;
4545
import software.amazon.opentelemetry.javaagent.providers.dynamicInstrumentation.output.DISnapshotOtlpEmitter;
46+
import software.amazon.opentelemetry.javaagent.providers.environment.EnvironmentResolver;
4647
import software.amazon.opentelemetry.javaagent.providers.exporter.otlp.aws.logs.OtlpAwsLogRecordExporterBuilder;
4748

4849
/**
@@ -481,13 +482,13 @@ public DynamicInstrumentationClient getClient() {
481482
private DISnapshotOtlpEmitter createOtlpEmitter(DynamicInstrumentationConfig diConfig) {
482483
String logsEndpoint = diConfig.getLogsEndpoint();
483484

484-
// Resource supplier: defers reading ResourceHolder until first emit
485+
// Resource supplier: defers reading ResourceHolder until first emit. aws.local.environment is
486+
// stamped via the SDK-only resolver (same precedence as the CloudWatch agent) so DI snapshot
487+
// telemetry correlates with Application Signals.
485488
Resource fallbackResource =
486-
Resource.create(
487-
Attributes.of(
488-
AttributeKey.stringKey("service.name"), diConfig.getServiceName(),
489-
AttributeKey.stringKey("deployment.environment"),
490-
diConfig.getDeploymentEnvironment()));
489+
EnvironmentResolver.withLocalEnvironment(
490+
Resource.create(
491+
Attributes.of(AttributeKey.stringKey("service.name"), diConfig.getServiceName())));
491492

492493
java.util.function.Supplier<Resource> resourceSupplier =
493494
() -> {
@@ -496,7 +497,7 @@ private DISnapshotOtlpEmitter createOtlpEmitter(DynamicInstrumentationConfig diC
496497
java.lang.reflect.Method getResource = holderClass.getMethod("getResource");
497498
Resource holderResource = (Resource) getResource.invoke(null);
498499
if (holderResource != null && !holderResource.equals(Resource.getDefault())) {
499-
return holderResource;
500+
return EnvironmentResolver.withLocalEnvironment(holderResource);
500501
}
501502
} catch (Throwable ignored) {
502503
// ResourceHolder not available or not yet populated — use fallback

awsagentprovider/src/main/java/software/amazon/opentelemetry/javaagent/providers/dynamicInstrumentation/config/DynamicInstrumentationConfig.java

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515

1616
package software.amazon.opentelemetry.javaagent.providers.dynamicInstrumentation.config;
1717

18-
import io.opentelemetry.api.common.AttributeKey;
1918
import io.opentelemetry.sdk.resources.Resource;
2019
import io.opentelemetry.semconv.ServiceAttributes;
2120
import java.lang.instrument.Instrumentation;
2221
import java.util.logging.Logger;
22+
import software.amazon.opentelemetry.javaagent.providers.environment.EnvironmentResolver;
2323

2424
/**
2525
* Configuration for the Dynamic Instrumentation feature. Holds all settings needed for
@@ -114,8 +114,19 @@ public String getServiceName() {
114114
}
115115

116116
/**
117-
* Extract deployment environment from resource with lazy-loading and caching. Only caches
118-
* successful environment resolution to handle timing issues with Resource population.
117+
* Compute {@code aws.local.environment} from the resource with lazy-loading and caching.
118+
*
119+
* <p>SDK-only environment resolution: rather than reading only an explicit {@code
120+
* deployment.environment[.name]}, this computes the full {@code aws.local.environment} from the
121+
* detected resource attributes using the same precedence as the CloudWatch agent (explicit &rarr;
122+
* {@code eks/k8s:<cluster>/<namespace>} &rarr; {@code ecs:<cluster>} &rarr; {@code ec2:<asg>}
123+
* &rarr; {@code ec2:default} &rarr; {@code generic:default}). This makes DI self-sufficient — the
124+
* value used as the request's Environment lookup key matches Application Signals without relying
125+
* on the agent proxy to inject it.
126+
*
127+
* <p>Only caches once the resource carries platform context, to handle timing issues with
128+
* Resource population; until then returns {@code "UnknownEnvironment"} without caching, allowing
129+
* automatic retry on the next call.
119130
*/
120131
public String getDeploymentEnvironment() {
121132
// Return cached value if we successfully found it before
@@ -129,17 +140,21 @@ public String getDeploymentEnvironment() {
129140
return "UnknownEnvironment"; // Don't cache - Resource might appear later
130141
}
131142

132-
String env = resource.getAttribute(AttributeKey.stringKey("deployment.environment.name"));
133-
if (env != null && !env.isEmpty()) {
134-
// SUCCESS! Cache it so we never query again
143+
String env = EnvironmentResolver.resolveLocalEnvironment(resource);
144+
145+
// Cache only once the resource has the platform context to resolve a concrete value.
146+
// The fallback values ("ec2:default", "generic:default") are also what a still-populating
147+
// resource momentarily yields (no cloud.platform / host / k8s attributes yet), so don't cache
148+
// those cases — the Resource may still be filling in. A specific value is always safe to cache.
149+
boolean isFallback = "ec2:default".equals(env) || "generic:default".equals(env);
150+
if (!env.isEmpty() && (!isFallback || EnvironmentResolver.hasPlatformContext(resource))) {
135151
cachedDeploymentEnvironment = env;
136152
logger.fine("AWS DI: Deployment environment resolved and cached: " + env);
137153
return env;
138154
}
139155

140-
// Attribute not available yet - don't cache, try again next time
141-
logger.fine(
142-
"AWS DI: deployment.environment.name attribute not yet available, will retry on next call");
156+
// Resource not populated yet - don't cache, try again next time
157+
logger.fine("AWS DI: environment not yet resolvable from resource, will retry on next call");
143158
return "UnknownEnvironment";
144159
}
145160

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
16+
package software.amazon.opentelemetry.javaagent.providers.environment;
17+
18+
import java.time.Duration;
19+
import java.util.logging.Level;
20+
import java.util.logging.Logger;
21+
import okhttp3.OkHttpClient;
22+
import okhttp3.Request;
23+
import okhttp3.RequestBody;
24+
import okhttp3.Response;
25+
26+
/**
27+
* Fetches the EC2 Auto Scaling group name from IMDS instance tags.
28+
*
29+
* <p>The stock OTel {@code Ec2Resource} only reads the instance-identity document — it does NOT
30+
* fetch instance tags, so the Auto Scaling group is absent from the SDK Resource. The CloudWatch
31+
* agent reads the ASG from IMDS instance tags ({@code
32+
* /latest/meta-data/tags/instance/aws:autoscaling:groupName}) to resolve {@code ec2:<asg>}. This
33+
* fetcher closes that gap so the SDK can compute the same {@code aws.local.environment} the agent
34+
* would on EC2, without depending on the agent.
35+
*
36+
* <p>Instance metadata tags must be enabled on the instance ({@code InstanceMetadataTags=enabled});
37+
* when they aren't (or IMDS is unreachable / not EC2), the fetcher returns an empty string and the
38+
* {@link EnvironmentResolver} falls back to {@code ec2:default} — matching the agent.
39+
*
40+
* <p>The fetch is invoked lazily by the resolver only on the EC2 branch, so EKS/ECS/explicit-env
41+
* workloads never pay the IMDS round-trip.
42+
*/
43+
public final class Ec2AutoScalingGroupFetcher {
44+
45+
private static final Logger logger = Logger.getLogger(Ec2AutoScalingGroupFetcher.class.getName());
46+
47+
private static final String DEFAULT_IMDS_ENDPOINT = "169.254.169.254";
48+
private static final String TOKEN_PATH = "/latest/api/token";
49+
private static final String ASG_TAG_PATH =
50+
"/latest/meta-data/tags/instance/aws:autoscaling:groupName";
51+
private static final Duration TIMEOUT = Duration.ofSeconds(1);
52+
private static final RequestBody EMPTY_BODY = RequestBody.create(new byte[0]);
53+
54+
private final String endpoint;
55+
56+
public Ec2AutoScalingGroupFetcher() {
57+
// Endpoint is overridable for tests via the same system property the OTel EC2 resource uses;
58+
// defaults to the EC2 link-local IMDS address.
59+
this(System.getProperty("otel.aws.imds.endpointOverride", DEFAULT_IMDS_ENDPOINT));
60+
}
61+
62+
// Visible for testing.
63+
Ec2AutoScalingGroupFetcher(String endpoint) {
64+
this.endpoint = endpoint;
65+
}
66+
67+
/**
68+
* Returns the Auto Scaling group name from IMDS instance tags, or an empty string when not on
69+
* EC2, IMDS is unreachable, or instance metadata tags are not enabled. Never throws.
70+
*/
71+
public String fetch() {
72+
try {
73+
OkHttpClient client =
74+
new OkHttpClient.Builder()
75+
.callTimeout(TIMEOUT)
76+
.connectTimeout(TIMEOUT)
77+
.readTimeout(TIMEOUT)
78+
.build();
79+
80+
String urlBase = "http://" + endpoint;
81+
String token = fetchToken(client, urlBase + TOKEN_PATH);
82+
String asg = fetchTag(client, urlBase + ASG_TAG_PATH, token);
83+
return asg == null ? "" : asg.trim();
84+
} catch (RuntimeException e) {
85+
// Not on EC2, IMDS unreachable, or instance tags not enabled — all expected, non-fatal.
86+
logger.log(Level.FINE, "EC2 Auto Scaling group not available via IMDS", e);
87+
return "";
88+
}
89+
}
90+
91+
private static String fetchToken(OkHttpClient client, String tokenUrl) {
92+
Request request =
93+
new Request.Builder()
94+
.url(tokenUrl)
95+
.method("PUT", EMPTY_BODY)
96+
.addHeader("X-aws-ec2-metadata-token-ttl-seconds", "60")
97+
.build();
98+
return execute(client, request);
99+
}
100+
101+
private static String fetchTag(OkHttpClient client, String tagUrl, String token) {
102+
Request.Builder builder = new Request.Builder().url(tagUrl).get();
103+
if (!token.isEmpty()) {
104+
builder.addHeader("X-aws-ec2-metadata-token", token);
105+
}
106+
return execute(client, builder.build());
107+
}
108+
109+
private static String execute(OkHttpClient client, Request request) {
110+
try (Response response = client.newCall(request).execute()) {
111+
if (response.code() != 200 || response.body() == null) {
112+
return "";
113+
}
114+
return response.body().string();
115+
} catch (Exception e) {
116+
logger.log(Level.FINE, "IMDS request failed: " + request.url(), e);
117+
return "";
118+
}
119+
}
120+
}

0 commit comments

Comments
 (0)