@odigos/aws-cdk 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,24 @@
1
- # Odigos CDK constructs for AWS Lambda
1
+ # Odigos CDK constructs for AWS
2
2
 
3
- Instrument AWS Lambda functions with the Odigos OpenTelemetry layer, from your own CDK app.
3
+ Instrument AWS Lambda functions and ECS Fargate services with OpenTelemetry, from your own CDK app.
4
4
 
5
5
  ```bash
6
6
  npm install @odigos/aws-cdk # TypeScript / JavaScript
7
7
  pip install odigos-aws-cdk # Python
8
8
  ```
9
9
 
10
+ | | Lambda | ECS Fargate |
11
+ |---|---|---|
12
+ | Construct | `OdigosLambdaInstrumentation` | `OdigosFargateInstrumentation` |
13
+ | Mechanism | a layer, plus an exec wrapper or loader variable | agents staged into a shared task volume by a run-to-completion container |
14
+ | Languages | python, nodejs, java, ruby | java, python, nodejs, dotnet, php, ruby |
15
+ | Where telemetry goes | a collector in the same Lambda sandbox, which forwards | your destination, from the application process |
16
+ | Language detection | from the function's declared runtime | none — you state the language |
17
+
18
+ Both write `ODIGOS_IAC` and never the connector's ownership marker: instrumented by Odigos, owned by you.
19
+
20
+ # AWS Lambda
21
+
10
22
  ```ts
11
23
  import { OdigosLambdaInstrumentation } from '@odigos/aws-cdk';
12
24
 
@@ -184,38 +196,22 @@ environment variable — readable by anyone with `lambda:GetFunctionConfiguratio
184
196
  console. For a long-lived credential, prefer a destination that authenticates by network position, or
185
197
  terminate at a collector you control.
186
198
 
187
- ## Making Odigos report the function as instrumented
188
-
189
- By default these constructs do **not** write `ODIGOS_MANAGED`, and the consequence is easy to hit: Odigos
190
- reports the function as `Absent` — "awaiting your deployment" — even though the layer is attached, the
191
- wrapper is set and the collector is configured. The connector derives ownership from that one variable, and
192
- checks for it *before* comparing anything else, so nothing about a correctly instrumented function
193
- substitutes for it.
194
-
195
- For a function Odigos discovers in **manual (IaC) mode** — which is the mode a workload is in unless you
196
- have explicitly handed instrumentation to the connector — turn it on:
197
-
198
- ```ts
199
- new OdigosLambdaInstrumentation(this, 'Odigos', {
200
- destination: { endpoint: 'otlp.example.com:4317' },
201
- functions: [fn],
202
- recordOwnershipMarker: true,
203
- });
204
- ```
199
+ ## How Odigos recognises the function
205
200
 
206
- The marker records the exact layer ARN the construct attached, which is also what the connector reads back
207
- to decide which layer version the function is *supposed* to carry — so a version you pinned is respected
208
- instead of being reported as drift. It works in `IN_ACCOUNT` mode too, where the ARN is a deploy-time
209
- reference your own code has no way to obtain.
201
+ These constructs write `ODIGOS_IAC` on every function they instrument, recording the layer ARN they attached
202
+ and the package version that attached it. That variable is what Odigos reads to attribute the function to
203
+ itself; without it a function reports as `Absent` — "awaiting your deployment" — however completely the layer,
204
+ wrapper and collector configuration are applied. There is nothing to switch on.
210
205
 
211
- It also records a rollback snapshot (`prevEnv`) naming every variable the construct set. That matters: a
212
- marker without one makes an uninstrument remove the **layer** while leaving `AWS_LAMBDA_EXEC_WRAPPER`
213
- pointing into it, which breaks the function at its next cold start. In web-server mode the snapshot holds
214
- your `existingLoaderValue`, so a rollback restores your own `NODE_OPTIONS` rather than deleting it.
206
+ The recorded layer ARN matters beyond attribution: it is what the connector checks the function against, so a
207
+ version you pinned is respected instead of being reported as drift the day Odigos publishes a newer one. It
208
+ works in `IN_ACCOUNT` mode too, where the ARN is a deploy-time reference your own code has no way to obtain.
215
209
 
216
- Leave it `false` for a function you have enabled as an **automatic-mode** connector source: there the
217
- connector owns the marker, including a snapshot it wrote itself, and two writers of one ownership record is
218
- the combination that genuinely misbehaves.
210
+ What these constructs never write is `ODIGOS_MANAGED`. That is the connector's own record of a mutation *it*
211
+ performed, including the `prevEnv` snapshot it restores from on rollback, and it is written only when you hand
212
+ a function to the connector by enabling it as an automatic-mode source. A CDK stack has no such snapshot to
213
+ offer — nothing was mutated, your stack *is* the function's definition — so writing it would claim a rollback
214
+ that was never recorded. Do not add it by hand.
219
215
 
220
216
  ## Service names
221
217
 
@@ -237,21 +233,209 @@ source ends up owned by both: each `cdk deploy` reasserts the CDK's environment,
237
233
  the connector's, and every round trip publishes a new function version.
238
234
 
239
235
  Pick one per function. Discovery-only (manual) mode is the combination that works: Odigos watches and
240
- reports, your stack owns the configuration — that is what `recordOwnershipMarker` is for.
236
+ reports, your stack owns the configuration. That is what `ODIGOS_IAC` expresses — instrumented by Odigos,
237
+ owned by you — and it is why these constructs never write the connector's ownership marker.
238
+
239
+ # ECS Fargate
240
+
241
+ ```ts
242
+ import { OdigosFargateInstrumentation, OdigosFargateLanguage } from '@odigos/aws-cdk';
243
+
244
+ const odigos = new OdigosFargateInstrumentation(this, 'Odigos', {
245
+ destination: { httpEndpoint: 'https://otlp.example.com:4318' },
246
+ });
247
+
248
+ odigos.instrument({
249
+ container: apiContainer, // the ecs.ContainerDefinition to instrument
250
+ language: OdigosFargateLanguage.JAVA,
251
+ service: apiService, // read OTEL_SERVICE_NAME off the ECS service
252
+ });
253
+ ```
254
+
255
+ ```python
256
+ from odigos_aws_cdk import OdigosFargateInstrumentation, OdigosFargateLanguage
257
+
258
+ odigos = OdigosFargateInstrumentation(self, "Odigos",
259
+ destination={"http_endpoint": "https://otlp.example.com:4318"},
260
+ )
261
+ odigos.instrument(
262
+ container=api_container,
263
+ language=OdigosFargateLanguage.JAVA,
264
+ service=api_service,
265
+ )
266
+ ```
267
+
268
+ ## What it does to the task definition
269
+
270
+ Four moves, which are one mechanism and only work together:
271
+
272
+ 1. a bare-name task volume, `odigos-agent` — on Fargate an ephemeral-storage bind mount scoped to the task
273
+ (there is no tmpfs);
274
+ 2. a **non-essential** `odigos-agent` container that copies the language agents out of the agents bundle
275
+ into that volume and exits 0;
276
+ 3. the volume mounted read-only at `/var/odigos` in your container, which `DependsOn` the staging container
277
+ with condition `SUCCESS` — legal only against a non-essential container, which is why (2) is
278
+ non-essential;
279
+ 4. the language's activation environment, the OTLP export environment and the `ODIGOS_IAC` marker on your
280
+ container.
281
+
282
+ Marker presence alone is a false green here. A container can carry a complete, correct activation
283
+ environment while the staging container is gone — the application then starts, finds nothing at
284
+ `/var/odigos`, and exports nothing while reporting as instrumented. That is why all four are one construct.
285
+
286
+ ## Where the telemetry goes
287
+
288
+ Straight to your destination, from inside the application process. **This is the substantive difference
289
+ from Lambda**, where the SDK hands batches to a collector in the same sandbox and the export happens after
290
+ the response has already been returned. A Fargate task has no such collector, so a slow or unreachable
291
+ destination is felt by the application's own exporter.
292
+
293
+ ## Naming the service
294
+
295
+ `OTEL_SERVICE_NAME` **must equal the workload's name in Odigos, which for a Fargate workload is the ECS
296
+ service name** — not the task definition family and not the container name. A different value is reported
297
+ as instrumentation drift rather than treated as a naming preference, so there is no default: pass `service`
298
+ to read the name off an `ecs.FargateService`, or `serviceName` when the service is defined elsewhere.
299
+
300
+ The service's physical name has to be one you set. A name CloudFormation generates is a `Ref` to the
301
+ service, and the task definition that service runs cannot reference it — CloudFormation rejects the cycle
302
+ at deploy time — so a generated name throws at synth pointing you at `serviceName`.
303
+
304
+ ## Languages, and stating them
305
+
306
+ | | Activated through | Needs `runtimeVersion` |
307
+ |---|---|---|
308
+ | `JAVA` | `JAVA_TOOL_OPTIONS` | no |
309
+ | `PYTHON` | `PYTHONPATH` | no |
310
+ | `NODEJS` | `NODE_OPTIONS` | no |
311
+ | `DOTNET` | `CORECLR_*` / `DOTNET_*` (glibc only) | no |
312
+ | `PHP` | `PHP_INI_SCAN_DIR` | **yes** — major.minor, e.g. `"8.3"` |
313
+ | `RUBY` | `RUBYOPT` | **yes** — major.minor, e.g. `"3.3"` |
314
+
315
+ There is no aspect and no detection, and unlike Lambda that is not a matter of taste. A function declares
316
+ its runtime; a container declares an image. A language guessed from an image would silently mis-instrument
317
+ whatever it got wrong — a Java agent staged into a Python container leaves it uninstrumented, and a
318
+ `JAVA_TOOL_OPTIONS` on a JVM-less image is simply ignored, so nothing fails loudly enough to notice.
319
+
320
+ php and ruby need `runtimeVersion` because their agents are laid down per runtime version in the bundle,
321
+ and the version selects the path their activation variable points at. It must be exactly `major.minor`;
322
+ anything else names a directory that does not exist, and the container starts with no agent loaded.
323
+
324
+ **Go cannot be instrumented on Fargate at all.** Its only zero-code instrumentation is eBPF, and Fargate
325
+ forbids the privileged containers and host PID an eBPF agent needs. Use the OpenTelemetry Go SDK.
326
+
327
+ ## Loader variables your container already sets
328
+
329
+ The construct reads the value your container was created with and **appends** to it, joined by the
330
+ separator the language's own pattern specifies — a space for `JAVA_TOOL_OPTIONS` and `NODE_OPTIONS`, a
331
+ colon for `PYTHONPATH`:
332
+
333
+ ```ts
334
+ taskDefinition.addContainer('App', {
335
+ image,
336
+ environment: { JAVA_TOOL_OPTIONS: '-Xmx512m' },
337
+ });
338
+ // becomes: -Xmx512m -javaagent:/var/odigos/java/javaagent.jar
339
+ ```
340
+
341
+ Pass `existingLoaderValue` when the value is not visible at synth — set by an `environmentFiles` entry,
342
+ baked into the image's own `ENV`, or supplied through `secrets`.
343
+
344
+ `PHP_INI_SCAN_DIR` and `RUBYOPT` are the exception: Odigos compares them **exactly**, so they are set
345
+ outright and a value already there is reported as a synth warning rather than merged. Fold what your
346
+ application needs into the value Odigos sets.
347
+
348
+ ## Transport
349
+
350
+ Each language's agent has a preferred transport, and giving the destination **both** endpoints lets each
351
+ one take it:
352
+
353
+ ```ts
354
+ destination: {
355
+ endpoint: 'otlp.example.com:4317', // OTLP/gRPC, bare host:port
356
+ httpEndpoint: 'https://otlp.example.com:4318', // OTLP/HTTP, scheme-qualified
357
+ }
358
+ ```
359
+
360
+ Every agent in the bundle prefers `http/protobuf`. java, python, nodejs and dotnet honour
361
+ `OTEL_EXPORTER_OTLP_PROTOCOL` and fall back to gRPC when that is all the destination offers; **php and
362
+ ruby cannot** — their community SDKs are `http/protobuf`-only, so instrumenting one against a gRPC-only
363
+ destination throws at synth rather than deploying a task that exports nothing.
364
+
365
+ Endpoint and protocol are one decision, and are compared as one. Pairing an endpoint with the other
366
+ protocol fails at runtime as a connection reset that points nowhere near the cause, so an endpoint on the
367
+ other protocol's standard port (4317 with `http/protobuf`, 4318 with `grpc`) is a synth warning.
368
+
369
+ Unlike Lambda, `destination.headers` is rendered into `OTEL_EXPORTER_OTLP_HEADERS` on the container. The
370
+ connector does not set or compare that variable when it instruments a Fargate task itself, so it is
371
+ neither reconciled nor reported as drift — it is here because a task with no collector of its own has
372
+ nowhere else to hold a credential. Same exposure caveat as Lambda: `ecs:DescribeTaskDefinition` and the
373
+ console can read it.
374
+
375
+ ## Signals
376
+
377
+ Same defaults as Lambda — traces on, metrics and logs opt-in — and the same requirement that they match
378
+ what Odigos has configured for the destination. All three `OTEL_*_EXPORTER` variables are written
379
+ explicitly here, unlike Lambda where an enabled signal's variable is left unset, because the connector's
380
+ Fargate check compares all three by value.
381
+
382
+ ## A task definition with more than one application
383
+
384
+ Six applications in six languages in one task definition is a real shape. Call `instrument()` once per
385
+ container; there is **one** staging container for the whole task definition, and its command is built
386
+ lazily from the union of every instrumented container's agents, in ascending directory order — which is
387
+ exactly what the connector compares it against.
388
+
389
+ ```ts
390
+ odigos.instrumentAll([
391
+ { container: apiContainer, language: OdigosFargateLanguage.JAVA, service: svc },
392
+ { container: workerContainer, language: OdigosFargateLanguage.PYTHON, service: svc },
393
+ ]);
394
+ ```
395
+
396
+ AWS caps a task definition at 10 containers and the staging container needs one of them; a definition
397
+ already at the limit fails at synth rather than on `RegisterTaskDefinition`.
398
+
399
+ ## The agents image
400
+
401
+ The staging container pulls `registry.odigos.io/odigos-fargate-agents` at this package's release tag — a
402
+ multi-arch manifest list, so amd64 and Graviton tasks both get a matching image. **It is pulled by your
403
+ task's execution role at every task start**, so a task in a private subnet with no NAT and no registry
404
+ endpoint cannot pull it. Mirror it into a registry your tasks can reach and pass `agentsImage`:
405
+
406
+ ```ts
407
+ agentsImage: '111122223333.dkr.ecr.eu-west-1.amazonaws.com/odigos-fargate-agents:v0.0.13',
408
+ ```
409
+
410
+ A mirror has to be the same bundle — the activation environment names exact paths inside it. The bundle is
411
+ Linux-only and glibc-based: a Windows task definition throws at synth, and musl (Alpine) images have no
412
+ .NET native profiler in it.
413
+
414
+ ## Do not also instrument these services with the connector
415
+
416
+ Same rule as Lambda, with ECS's own cost. The connector instruments a live service by registering a new
417
+ task-definition revision and updating the service to it. A service instrumented here *and* enabled as an
418
+ automatic-mode source is owned by both: each `cdk deploy` reasserts your stack's definition, each
419
+ reconcile reasserts the connector's, and every round trip registers a revision and rolls the service.
420
+ Discovery-only (manual) mode is the combination that works.
241
421
 
242
- ## API
422
+ # API
243
423
 
244
424
  | Export | |
245
425
  |---|---|
246
- | `OdigosLambdaInstrumentation` | the construct; `instrument(fn)`, `instrumentAll(fns)` |
426
+ | `OdigosLambdaInstrumentation` | the Lambda construct; `instrument(fn)`, `instrumentAll(fns)` |
247
427
  | `OdigosInstrumentationAspect` | applies it to every function in a scope |
248
- | `OdigosDestination` | where the collector exports; exactly one of `endpoint` / `httpEndpoint` |
428
+ | `OdigosFargateInstrumentation` | the Fargate construct; `instrument(target)`, `instrumentAll(targets)` |
429
+ | `OdigosFargateTarget` | one container to instrument: `container`, `language`, `service` / `serviceName`, `runtimeVersion`, `existingLoaderValue` |
430
+ | `OdigosFargateLanguage` | `JAVA` \| `PYTHON` \| `NODEJS` \| `DOTNET` \| `PHP` \| `RUBY` |
431
+ | `OdigosDestination` | where telemetry goes; exactly one endpoint on Lambda, either or both on Fargate |
249
432
  | `OdigosLayerSource` | `ODIGOS_PUBLIC` \| `IN_ACCOUNT` |
250
433
  | `OdigosInstrumentationMode` | `HANDLER` \| `WEB_SERVER` |
251
434
  | `OdigosJavaHandlerDistro` | the four Java wrappers |
252
- | `recordOwnershipMarker` | the option that makes Odigos recognise the function as instrumented |
253
- | `renderCollectorConfig` | the collector config renderer, if you want to inspect what gets set |
435
+ | `renderCollectorConfig` | the Lambda collector config renderer, if you want to inspect what gets set |
436
+ | `sidecarCommand`, `evaluateAppendPattern` | the Fargate staging command and loader-value rendering |
254
437
  | `LAYER_ARNS`, `ODIGOS_VERSION` | the baked layer table and the release it came from |
438
+ | `FARGATE_AGENTS_IMAGE`, `FARGATE_DISTROS_VERSION` | the agents image this build stages, and the distros release its recipes mirror |
255
439
 
256
440
  ## License
257
441
 
@@ -0,0 +1,108 @@
1
+ import { OdigosDestination, OdigosFargateLanguage } from './types';
2
+ /** `OTEL_EXPORTER_OTLP_PROTOCOL` values. Endpoint and protocol are always decided together. */
3
+ export declare const OTLP_PROTOCOL_GRPC = "grpc";
4
+ export declare const OTLP_PROTOCOL_HTTP = "http/protobuf";
5
+ /**
6
+ * An append-style loader variable — `JAVA_TOOL_OPTIONS`, `NODE_OPTIONS`, `PYTHONPATH` — kept as its
7
+ * pattern rather than a finished value, because the value depends on what the container already carries.
8
+ */
9
+ export interface FargateAppendEnvVar {
10
+ /** Variable name. */
11
+ readonly name: string;
12
+ /** The distro's ReplacePattern, placeholders unresolved. */
13
+ readonly pattern: string;
14
+ }
15
+ /**
16
+ * Resolves an append pattern for a given pre-existing value.
17
+ *
18
+ * Port of `distro.EvaluateReplacePattern`, INCLUDING the empty-original case: with nothing to join to,
19
+ * the placeholder and its adjacent delimiter are stripped, so the result never opens with a stray space
20
+ * or colon. `PYTHONPATH=":/var/odigos/python"` would put the current working directory on `sys.path`
21
+ * ahead of the agent, which is a different program than the one intended.
22
+ */
23
+ export declare function evaluateAppendPattern(pattern: string, originalValue: string): string;
24
+ /**
25
+ * The delimiter a pattern joins the container's own value to ours with: the character immediately after
26
+ * the original-value placeholder.
27
+ *
28
+ * DERIVED from the pattern rather than declared, because the pattern is the authority — it comes from the
29
+ * upstream distro, differs by language (a space for `JAVA_TOOL_OPTIONS`/`NODE_OPTIONS`, a colon for
30
+ * `PYTHONPATH`) and can change on somebody else's release. Assuming one would be silently destructive in
31
+ * a way nothing here would catch: a space-joined `PYTHONPATH` is one unusable path rather than two, and
32
+ * the process still starts.
33
+ */
34
+ export declare function appendSeparator(pattern: string): string;
35
+ /** One language's staging and activation, with every platform-supplied parameter already rendered. */
36
+ export interface FargateRecipe {
37
+ /** Canonical language key, and the value recorded in the `ODIGOS_IAC` marker's `lang`. */
38
+ readonly language: string;
39
+ /** Directories under the agents image the staging container copies into the shared volume. */
40
+ readonly agentDirs: string[];
41
+ /** Activation variables set outright. */
42
+ readonly staticEnv: {
43
+ [key: string]: string;
44
+ };
45
+ /** Activation variables our fragment is appended to. */
46
+ readonly appendEnv: FargateAppendEnvVar[];
47
+ /** The transport this agent prefers. */
48
+ readonly otlpProtocol: string;
49
+ /** Whether the agent can speak only `otlpProtocol` and cannot be switched to the other transport. */
50
+ readonly protocolFixed: boolean;
51
+ }
52
+ /** The canonical connector language key for an enum member. The enum's values ARE those keys. */
53
+ export declare function languageKeyOf(language: OdigosFargateLanguage): string;
54
+ /**
55
+ * The recipe for a language, with the runtime version rendered into the agents' paths where the language
56
+ * needs it.
57
+ *
58
+ * @param runtimeVersion the application's runtime `major.minor`. Required for php and ruby, whose agent
59
+ * files are laid down per version, and ignored by every other language.
60
+ */
61
+ export declare function recipeFor(language: OdigosFargateLanguage, runtimeVersion?: string): FargateRecipe;
62
+ /**
63
+ * The staging container's command: create the shared directory, then copy each language's agent
64
+ * directory out of the image into it, then exit 0.
65
+ *
66
+ * ORDER AND SPELLING ARE THE CONTRACT. There is ONE staging container per task definition however many
67
+ * languages it holds, and the connector compares its command against the union of every instrumented
68
+ * container's agent directories in ascending order. A command that differs by a space is reported as
69
+ * drift on the whole task definition, so this is sorted and rendered exactly as `sidecarCommand` in the
70
+ * connector does it.
71
+ */
72
+ export declare function sidecarCommand(agentDirs: string[]): string[];
73
+ /**
74
+ * The resource attributes every span, metric and log from an instrumented task carries. `service.name` is
75
+ * set separately, through `OTEL_SERVICE_NAME`.
76
+ *
77
+ * The region may be an unresolved token on an environment-agnostic stack, which is fine here and unlike
78
+ * the Lambda layer lookup: this value is an environment variable, so the token resolves to a `Ref` to
79
+ * `AWS::Region` and CloudFormation fills in the real region at deploy time.
80
+ */
81
+ export declare function fargateResourceAttributes(region: string): string;
82
+ /** Where an instrumented container exports to, and over which transport. Always decided as a pair. */
83
+ export interface FargateExportTarget {
84
+ /** `scheme://host:port`. */
85
+ readonly endpoint: string;
86
+ /** `grpc` or `http/protobuf`. */
87
+ readonly protocol: string;
88
+ }
89
+ /**
90
+ * Resolves the endpoint and protocol for one recipe against one destination.
91
+ *
92
+ * Port of `exportTargetFor`. A Fargate task has no node-local collector — unlike Lambda, where the SDK
93
+ * exports to a collector in the same sandbox — so the agent exports OTLP straight to the destination, and
94
+ * which transport it can use is a property of the agent as much as of the destination.
95
+ *
96
+ * Each agent's PREFERRED transport is taken when the destination offers it. When it does not, an agent
97
+ * whose community SDK pins the protocol (php, ruby) cannot be switched and throws; every other agent
98
+ * honours `OTEL_EXPORTER_OTLP_PROTOCOL` and falls back to whichever transport exists.
99
+ */
100
+ export declare function exportTargetFor(recipe: FargateRecipe, dest: OdigosDestination): FargateExportTarget;
101
+ /**
102
+ * A warning when an endpoint's port is the well-known port of the OTHER OTLP protocol, or "" when the
103
+ * pairing looks right — including a non-standard port, which cannot be judged.
104
+ *
105
+ * Worth a synth-time warning because the runtime symptom is a connection reset mid-export, which points
106
+ * nowhere near the cause: an HTTP request against an HTTP/2 gRPC listener, or the reverse.
107
+ */
108
+ export declare function portProtocolMismatch(endpoint: string, protocol: string): string;