@odigos/aws-cdk
v0.0.15
Published
CDK constructs that instrument AWS Lambda functions with the Odigos OpenTelemetry layer
Downloads
459
Readme
Odigos CDK constructs for AWS
Instrument AWS Lambda functions and ECS Fargate services with OpenTelemetry, from your own CDK app.
npm install @odigos/aws-cdk # TypeScript / JavaScript
pip install odigos-aws-cdk # Python| | Lambda | ECS Fargate |
|---|---|---|
| Construct | OdigosLambdaInstrumentation | OdigosFargateInstrumentation |
| Mechanism | a layer, plus an exec wrapper or loader variable | agents staged into a shared task volume by a run-to-completion container |
| Languages | python, nodejs, java, ruby | java, python, nodejs, dotnet, php, ruby |
| Where telemetry goes | a collector in the same Lambda sandbox, which forwards | your destination, from the application process |
| Language detection | from the function's declared runtime | none — you state the language |
Both write ODIGOS_IAC and never the connector's ownership marker: instrumented by Odigos, owned by you.
AWS Lambda
import { OdigosLambdaInstrumentation } from '@odigos/aws-cdk';
new OdigosLambdaInstrumentation(this, 'Odigos', {
destination: { endpoint: 'otlp.example.com:4317' },
functions: [apiFn, workerFn],
});from odigos_aws_cdk import OdigosLambdaInstrumentation
OdigosLambdaInstrumentation(self, "Odigos",
destination={"endpoint": "otlp.example.com:4317"},
functions=[api_fn, worker_fn],
)That attaches the layer for each function's language and architecture, sets the exec wrapper that loads the OpenTelemetry SDK, and hands the in-Lambda collector its whole configuration.
How the telemetry actually leaves the function
Your function's SDK does not export to your destination. It exports to an OpenTelemetry collector running inside the same Lambda sandbox, on localhost. The collector forwards to your destination after the handler has already returned its response.
That indirection is the whole point. A slow or unreachable destination costs billed duration, not caller
latency — and the collector's decouple processor is what holds the sandbox open until the export queue
drains, so a low-traffic function does not lose its telemetry to the freeze.
Consequently OTEL_EXPORTER_OTLP_ENDPOINT is deliberately never set. If you set it yourself you put the
export back on the invocation's critical path.
Endpoint grammar
endpoint is OTLP/gRPC as bare host:port (conventionally 4317). httpEndpoint is OTLP/HTTP as a
scheme-qualified URL (conventionally 4318). Exactly one. They select different collector exporters with
different endpoint grammars, so a scheme in endpoint — or its absence in httpEndpoint — throws at synth
rather than producing a collector that starts and silently delivers nothing. A gRPC endpoint on port 4318
(or an HTTP one on 4317) is the likeliest version of that mistake, and is reported as a synth warning.
Choosing a layer source
new OdigosLambdaInstrumentation(this, 'Odigos', {
destination: { endpoint: 'otlp.example.com:4317' },
functions: [fn],
layerSource: OdigosLayerSource.IN_ACCOUNT,
});| | ODIGOS_PUBLIC (default) | IN_ACCOUNT |
|---|---|---|
| What it synthesizes | a layer ARN | an AWS::Serverless::Application (nested stack) per language/arch |
| Layer owner | the Odigos AWS account | your account, created by your stack |
| Resolution | synth time, from a table baked into this package | deploy time, from the Serverless Application Repository |
| Environment-agnostic stacks | not supported — needs a concrete region | works |
| Deploy speed | unchanged | slower on first deploy |
Both install identical bytes. IN_ACCOUNT exists for organisations whose policy forbids attaching a
Lambda layer owned by another AWS account. It needs the deploying principal to be allowed
serverlessrepo:CreateCloudFormationTemplate and serverlessrepo:GetApplication.
If you mirror the layer somewhere yourself, pass layerVersion and neither path is used.
Versioning
The package version is the layer version. @odigos/[email protected] resolves to the layer built and
published by Odigos release v0.2.0, and pins the SAR application to semantic version 0.2.0 — which is
immutable, so it means the same bytes forever.
Nothing resolves at deploy time in the default path, so upgrading the layer is npm update and a
cdk diff that shows the change. An instrumentation layer moving on its own, under a deployment that
changed nothing, is not a property you want.
Handler mode vs. web-server mode
OdigosInstrumentationMode.HANDLER (the default) is for a normal Lambda handler. It sets
AWS_LAMBDA_EXEC_WRAPPER.
OdigosInstrumentationMode.WEB_SERVER is for a function running a web server behind the
AWS Lambda Web Adapter. The adapter owns the exec
wrapper, so this mode does not touch it and loads instrumentation through the language's own loader
variable instead.
new OdigosLambdaInstrumentation(this, 'Odigos', {
destination: { endpoint: 'otlp.example.com:4317' },
functions: [webFn],
mode: OdigosInstrumentationMode.WEB_SERVER,
// NODE_OPTIONS the function already needs — ours is appended, not substituted.
existingLoaderValue: '--max-old-space-size=2048',
});There is no auto-detection, deliberately. Detecting the adapter means inspecting the function's other
layers, which at synth time are frequently unresolved tokens; a detector that was right most of the time
would silently break the functions it got wrong. Applying HANDLER to a web-adapter function
overwrites the adapter's wrapper and the function stops serving.
existingLoaderValue has to be passed explicitly because CDK's lambda.Function exposes no way to read
back an environment variable set elsewhere in your app. Omit it and the loader variable is set to the
Odigos value alone — for NODE_OPTIONS that can mean losing a flag your app needs to boot.
Ruby has no verified web-server loader and is rejected at synth in this mode.
Java handler interfaces
The Java layer ships four wrapper scripts, one per handler interface, and this is a correctness switch rather than a detail level:
javaHandlerDistro: OdigosJavaHandlerDistro.OTEL_STREAM_HANDLER| Distro | For |
|---|---|
| OTEL_HANDLER (default) | RequestHandler |
| OTEL_PROXY_HANDLER | API Gateway proxy events (enriched) |
| OTEL_SQS_HANDLER | SQS events (enriched) |
| OTEL_STREAM_HANDLER | RequestStreamHandler |
OTEL_HANDLER installs a RequestHandler implementation. A RequestStreamHandler function has a
different signature entirely, so wrapping it with the default breaks the invocation — it does not
merely produce thinner spans.
Signals
Traces only, by default. Metrics and logs are opt-in:
destination: {
endpoint: 'otlp.example.com:4317',
metrics: true, // off unless you ask
logs: true, // off unless you ask
}They are opt-in because the cost of a signal the destination does not implement falls on the function: every batch is refused and the in-Lambda collector retries it on an exponential backoff, inside billed duration. Traces are what nearly every destination accepts; the other two are a decision about your destination, so they are made deliberately.
A disabled signal gets no collector pipeline and the SDK is told not to produce it
(OTEL_METRICS_EXPORTER=none). Both halves matter: a signal with no pipeline is refused at the receiver on
every batch, so suppressing it at the source is what stops the error loop. An enabled signal's variable
stays unset, because otlp is already the SDK default.
traces: false with neither of the others on throws at synth — a collector with no pipeline fails to
start, which would leave the function instrumented and silent.
If Odigos also manages this function in manual mode, the signals have to match. The connector renders the collector configuration from the destination configured in Odigos and compares the whole string, so a traces-only function judged against an all-three destination is reported as drifted on the collector-export component. Enable the same set on both sides.
Instrumenting a whole stack
Aspects.of(stack).add(new OdigosInstrumentationAspect(stack, {
destination: { endpoint: 'otlp.example.com:4317' },
}));skipUnsupportedRuntimes defaults to true for the aspect: it visits every function in the scope,
including ones whose runtime has no OpenTelemetry Lambda layer (dotnet*, provided.* for Go and custom
runtimes). Each skip is a synth warning rather than a silent no-op. The construct defaults it to false,
because explicitly asking to instrument a Go function is a mistake worth failing on.
Credentials in headers
destination.headers is rendered into OPENTELEMETRY_COLLECTOR_CONFIG_URI, which is a plain Lambda
environment variable — readable by anyone with lambda:GetFunctionConfiguration and visible in the
console. For a long-lived credential, prefer a destination that authenticates by network position, or
terminate at a collector you control.
How Odigos recognises the function
These constructs write ODIGOS_IAC on every function they instrument, recording the layer ARN they attached
and the package version that attached it. That variable is what Odigos reads to attribute the function to
itself; without it a function reports as Absent — "awaiting your deployment" — however completely the layer,
wrapper and collector configuration are applied. There is nothing to switch on.
The recorded layer ARN matters beyond attribution: it is what the connector checks the function against, so a
version you pinned is respected instead of being reported as drift the day Odigos publishes a newer one. It
works in IN_ACCOUNT mode too, where the ARN is a deploy-time reference your own code has no way to obtain.
What these constructs never write is ODIGOS_MANAGED. That is the connector's own record of a mutation it
performed, including the prevEnv snapshot it restores from on rollback, and it is written only when you hand
a function to the connector by enabling it as an automatic-mode source. A CDK stack has no such snapshot to
offer — nothing was mutated, your stack is the function's definition — so writing it would claim a rollback
that was never recorded. Do not add it by hand.
Service names
Left unset, OTEL_SERVICE_NAME is the function's physical name when your stack names it explicitly, and
the construct id otherwise. The fallback exists because a name CloudFormation generates is a Ref to the
function, and a function referencing itself in its own environment is a dependency cycle CloudFormation
rejects at deploy time.
The connector compares OTEL_SERVICE_NAME against the workload's name in Odigos, which is the real AWS
function name — so for a function whose name is generated, pass serviceName explicitly or it is reported
as drifted. serviceName applies to every function a construct instruments, so use one construct per
function when you want per-function names.
Do not also instrument these functions with the Odigos connector
The Odigos AWS connector instruments live functions by mutating them, and reconciles them back to its
own desired state on every pass. A function instrumented here and enabled as an automatic-mode connector
source ends up owned by both: each cdk deploy reasserts the CDK's environment, each reconcile reasserts
the connector's, and every round trip publishes a new function version.
Pick one per function. Discovery-only (manual) mode is the combination that works: Odigos watches and
reports, your stack owns the configuration. That is what ODIGOS_IAC expresses — instrumented by Odigos,
owned by you — and it is why these constructs never write the connector's ownership marker.
ECS Fargate
import { OdigosFargateInstrumentation, OdigosFargateLanguage } from '@odigos/aws-cdk';
const odigos = new OdigosFargateInstrumentation(this, 'Odigos', {
destination: { httpEndpoint: 'https://otlp.example.com:4318' },
});
odigos.instrument({
container: apiContainer, // the ecs.ContainerDefinition to instrument
language: OdigosFargateLanguage.JAVA,
service: apiService, // read OTEL_SERVICE_NAME off the ECS service
});from odigos_aws_cdk import OdigosFargateInstrumentation, OdigosFargateLanguage
odigos = OdigosFargateInstrumentation(self, "Odigos",
destination={"http_endpoint": "https://otlp.example.com:4318"},
)
odigos.instrument(
container=api_container,
language=OdigosFargateLanguage.JAVA,
service=api_service,
)What it does to the task definition
Four moves, which are one mechanism and only work together:
- a bare-name task volume,
odigos-agent— on Fargate an ephemeral-storage bind mount scoped to the task (there is no tmpfs); - a non-essential
odigos-agentcontainer that copies the language agents out of the agents bundle into that volume and exits 0; - the volume mounted read-only at
/var/odigosin your container, whichDependsOnthe staging container with conditionSUCCESS— legal only against a non-essential container, which is why (2) is non-essential; - the language's activation environment, the OTLP export environment and the
ODIGOS_IACmarker on your container.
Marker presence alone is a false green here. A container can carry a complete, correct activation
environment while the staging container is gone — the application then starts, finds nothing at
/var/odigos, and exports nothing while reporting as instrumented. That is why all four are one construct.
Where the telemetry goes
Straight to your destination, from inside the application process. This is the substantive difference from Lambda, where the SDK hands batches to a collector in the same sandbox and the export happens after the response has already been returned. A Fargate task has no such collector, so a slow or unreachable destination is felt by the application's own exporter.
Naming the service
OTEL_SERVICE_NAME must equal the workload's name in Odigos, which for a Fargate workload is the ECS
service name — not the task definition family and not the container name. A different value is reported
as instrumentation drift rather than treated as a naming preference, so there is no default: pass service
to read the name off an ecs.FargateService, or serviceName when the service is defined elsewhere.
The service's physical name has to be one you set. A name CloudFormation generates is a Ref to the
service, and the task definition that service runs cannot reference it — CloudFormation rejects the cycle
at deploy time — so a generated name throws at synth pointing you at serviceName.
Languages, and stating them
| | Activated through | Needs runtimeVersion |
|---|---|---|
| JAVA | JAVA_TOOL_OPTIONS | no |
| PYTHON | PYTHONPATH | no |
| NODEJS | NODE_OPTIONS | no |
| DOTNET | CORECLR_* / DOTNET_* (glibc only) | no |
| PHP | PHP_INI_SCAN_DIR | yes — major.minor, e.g. "8.3" |
| RUBY | RUBYOPT | yes — major.minor, e.g. "3.3" |
There is no aspect and no detection, and unlike Lambda that is not a matter of taste. A function declares
its runtime; a container declares an image. A language guessed from an image would silently mis-instrument
whatever it got wrong — a Java agent staged into a Python container leaves it uninstrumented, and a
JAVA_TOOL_OPTIONS on a JVM-less image is simply ignored, so nothing fails loudly enough to notice.
php and ruby need runtimeVersion because their agents are laid down per runtime version in the bundle,
and the version selects the path their activation variable points at. It must be exactly major.minor;
anything else names a directory that does not exist, and the container starts with no agent loaded.
Go cannot be instrumented on Fargate at all. Its only zero-code instrumentation is eBPF, and Fargate forbids the privileged containers and host PID an eBPF agent needs. Use the OpenTelemetry Go SDK.
Loader variables your container already sets
The construct reads the value your container was created with and appends to it, joined by the
separator the language's own pattern specifies — a space for JAVA_TOOL_OPTIONS and NODE_OPTIONS, a
colon for PYTHONPATH:
taskDefinition.addContainer('App', {
image,
environment: { JAVA_TOOL_OPTIONS: '-Xmx512m' },
});
// becomes: -Xmx512m -javaagent:/var/odigos/java/javaagent.jarPass existingLoaderValue when the value is not visible at synth — set by an environmentFiles entry,
baked into the image's own ENV, or supplied through secrets.
PHP_INI_SCAN_DIR and RUBYOPT are the exception: Odigos compares them exactly, so they are set
outright and a value already there is reported as a synth warning rather than merged. Fold what your
application needs into the value Odigos sets.
Transport
Each language's agent has a preferred transport, and giving the destination both endpoints lets each one take it:
destination: {
endpoint: 'otlp.example.com:4317', // OTLP/gRPC, bare host:port
httpEndpoint: 'https://otlp.example.com:4318', // OTLP/HTTP, scheme-qualified
}Every agent in the bundle prefers http/protobuf. java, python, nodejs and dotnet honour
OTEL_EXPORTER_OTLP_PROTOCOL and fall back to gRPC when that is all the destination offers; php and
ruby cannot — their community SDKs are http/protobuf-only, so instrumenting one against a gRPC-only
destination throws at synth rather than deploying a task that exports nothing.
Endpoint and protocol are one decision, and are compared as one. Pairing an endpoint with the other
protocol fails at runtime as a connection reset that points nowhere near the cause, so an endpoint on the
other protocol's standard port (4317 with http/protobuf, 4318 with grpc) is a synth warning.
Unlike Lambda, destination.headers is rendered into OTEL_EXPORTER_OTLP_HEADERS on the container. The
connector does not set or compare that variable when it instruments a Fargate task itself, so it is
neither reconciled nor reported as drift — it is here because a task with no collector of its own has
nowhere else to hold a credential. Same exposure caveat as Lambda: ecs:DescribeTaskDefinition and the
console can read it.
Signals
Same defaults as Lambda — traces on, metrics and logs opt-in — and the same requirement that they match
what Odigos has configured for the destination. All three OTEL_*_EXPORTER variables are written
explicitly here, unlike Lambda where an enabled signal's variable is left unset, because the connector's
Fargate check compares all three by value.
A task definition with more than one application
Six applications in six languages in one task definition is a real shape. Call instrument() once per
container; there is one staging container for the whole task definition, and its command is built
lazily from the union of every instrumented container's agents, in ascending directory order — which is
exactly what the connector compares it against.
odigos.instrumentAll([
{ container: apiContainer, language: OdigosFargateLanguage.JAVA, service: svc },
{ container: workerContainer, language: OdigosFargateLanguage.PYTHON, service: svc },
]);AWS caps a task definition at 10 containers and the staging container needs one of them; a definition
already at the limit fails at synth rather than on RegisterTaskDefinition.
The agents image
The staging container pulls registry.odigos.io/odigos-fargate-agents at this package's release tag — a
multi-arch manifest list, so amd64 and Graviton tasks both get a matching image. It is pulled by your
task's execution role at every task start, so a task in a private subnet with no NAT and no registry
endpoint cannot pull it. Mirror it into a registry your tasks can reach and pass agentsImage:
agentsImage: '111122223333.dkr.ecr.eu-west-1.amazonaws.com/odigos-fargate-agents:v0.0.13',A mirror has to be the same bundle — the activation environment names exact paths inside it. The bundle is Linux-only and glibc-based: a Windows task definition throws at synth, and musl (Alpine) images have no .NET native profiler in it.
Whatever image you stage from is recorded in the marker, as image, and Odigos holds the task
definition to that rather than to the bundle the connector's own build pins. Two consequences worth
knowing: a mirror is judged against itself instead of reading as drift, and upgrading this package ahead of
the connector is not drift either — the service stays conformant until you re-deploy, exactly as a Lambda
function pinned to an older layer does. What is reported is two containers in one task definition
recording different images: there is one staging container and it cannot serve two bundles.
Do not also instrument these services with the connector
Same rule as Lambda, with ECS's own cost. The connector instruments a live service by registering a new
task-definition revision and updating the service to it. A service instrumented here and enabled as an
automatic-mode source is owned by both: each cdk deploy reasserts your stack's definition, each
reconcile reasserts the connector's, and every round trip registers a revision and rolls the service.
Discovery-only (manual) mode is the combination that works.
API
| Export | |
|---|---|
| OdigosLambdaInstrumentation | the Lambda construct; instrument(fn), instrumentAll(fns) |
| OdigosInstrumentationAspect | applies it to every function in a scope |
| OdigosFargateInstrumentation | the Fargate construct; instrument(target), instrumentAll(targets) |
| OdigosFargateTarget | one container to instrument: container, language, service / serviceName, runtimeVersion, existingLoaderValue |
| OdigosFargateLanguage | JAVA | PYTHON | NODEJS | DOTNET | PHP | RUBY |
| OdigosDestination | where telemetry goes; exactly one endpoint on Lambda, either or both on Fargate |
| OdigosLayerSource | ODIGOS_PUBLIC | IN_ACCOUNT |
| OdigosInstrumentationMode | HANDLER | WEB_SERVER |
| OdigosJavaHandlerDistro | the four Java wrappers |
| renderCollectorConfig | the Lambda collector config renderer, if you want to inspect what gets set |
| sidecarCommand, evaluateAppendPattern | the Fargate staging command and loader-value rendering |
| LAYER_ARNS, ODIGOS_VERSION | the baked layer table and the release it came from |
| FARGATE_AGENTS_IMAGE, FARGATE_DISTROS_VERSION | the agents image this build stages, and the distros release its recipes mirror |
License
Apache-2.0.
