@opensearch-project/agent-health 0.2.0 → 0.4.0
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 +210 -298
- package/cli/dist/index.js +1438 -110
- package/deployment/cloudformation/agent-health-observability.yaml +571 -0
- package/dist/assets/index-BOIP5L7h.js +246 -0
- package/dist/assets/index-CU9YKpAL.css +1 -0
- package/dist/index.html +2 -2
- package/lib/dist/config/index.js +50 -2
- package/lib/dist/index.js +66 -6
- package/package.json +25 -8
- package/server/dist/app.js +7495 -2524
- package/server/dist/index.js +5531 -328
- package/dist/assets/index-4BAkkFzo.js +0 -267
- package/dist/assets/index-C3K5cBQr.css +0 -1
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
# Copyright OpenSearch Contributors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Agent Health — CloudFormation template for managed observability infrastructure.
|
|
5
|
+
# Deploys an Amazon OpenSearch Service domain and OpenSearch Ingestion (OSIS) pipeline
|
|
6
|
+
# for collecting OpenTelemetry traces from AI agents.
|
|
7
|
+
#
|
|
8
|
+
# Launch Stack URL pattern (replace REGION):
|
|
9
|
+
# https://console.aws.amazon.com/cloudformation/home?region=REGION#/stacks/create/template?stackName=AgentHealthObservability&templateURL=https://agent-health-cfn-REGION.s3.REGION.amazonaws.com/agent-health-observability.yaml
|
|
10
|
+
|
|
11
|
+
AWSTemplateFormatVersion: '2010-09-09'
|
|
12
|
+
Description: >-
|
|
13
|
+
Agent Health Observability Stack — Amazon OpenSearch Service domain with
|
|
14
|
+
OpenSearch Ingestion (OSIS) pipeline for OTLP trace collection from AI agents.
|
|
15
|
+
|
|
16
|
+
# =============================================================================
|
|
17
|
+
# Parameters
|
|
18
|
+
# =============================================================================
|
|
19
|
+
Parameters:
|
|
20
|
+
DomainName:
|
|
21
|
+
Type: String
|
|
22
|
+
Default: agent-health-traces
|
|
23
|
+
Description: Name for the OpenSearch Service domain
|
|
24
|
+
AllowedPattern: '[a-z][a-z0-9\-]+'
|
|
25
|
+
MinLength: 3
|
|
26
|
+
MaxLength: 28
|
|
27
|
+
|
|
28
|
+
InstanceType:
|
|
29
|
+
Type: String
|
|
30
|
+
Default: r8g.large.search
|
|
31
|
+
Description: OpenSearch instance type
|
|
32
|
+
AllowedValues:
|
|
33
|
+
- t3.small.search
|
|
34
|
+
- t3.medium.search
|
|
35
|
+
- r8g.large.search
|
|
36
|
+
- r8g.xlarge.search
|
|
37
|
+
- r8g.2xlarge.search
|
|
38
|
+
|
|
39
|
+
VolumeSize:
|
|
40
|
+
Type: Number
|
|
41
|
+
Default: 100
|
|
42
|
+
Description: EBS volume size in GB per data node
|
|
43
|
+
MinValue: 10
|
|
44
|
+
MaxValue: 1000
|
|
45
|
+
|
|
46
|
+
PipelineMinUnits:
|
|
47
|
+
Type: Number
|
|
48
|
+
Default: 1
|
|
49
|
+
Description: Minimum OSIS pipeline capacity units
|
|
50
|
+
MinValue: 1
|
|
51
|
+
MaxValue: 96
|
|
52
|
+
|
|
53
|
+
PipelineMaxUnits:
|
|
54
|
+
Type: Number
|
|
55
|
+
Default: 4
|
|
56
|
+
Description: Maximum OSIS pipeline capacity units
|
|
57
|
+
MinValue: 1
|
|
58
|
+
MaxValue: 96
|
|
59
|
+
|
|
60
|
+
MasterUserARN:
|
|
61
|
+
Type: String
|
|
62
|
+
Description: >-
|
|
63
|
+
IAM ARN for the OpenSearch FGAC master user (used for security plugin admin).
|
|
64
|
+
Defaults to the account root; override with your IAM role ARN for direct access.
|
|
65
|
+
Default: ''
|
|
66
|
+
|
|
67
|
+
# =============================================================================
|
|
68
|
+
# Conditions
|
|
69
|
+
# =============================================================================
|
|
70
|
+
Conditions:
|
|
71
|
+
HasMasterUserARN: !Not [!Equals [!Ref MasterUserARN, '']]
|
|
72
|
+
|
|
73
|
+
# =============================================================================
|
|
74
|
+
# Resources
|
|
75
|
+
# =============================================================================
|
|
76
|
+
Resources:
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# OpenSearch Service Domain
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
OpenSearchDomain:
|
|
82
|
+
Type: AWS::OpenSearchService::Domain
|
|
83
|
+
Properties:
|
|
84
|
+
DomainName: !Ref DomainName
|
|
85
|
+
EngineVersion: OpenSearch_3.5
|
|
86
|
+
ClusterConfig:
|
|
87
|
+
InstanceType: !Ref InstanceType
|
|
88
|
+
InstanceCount: 3
|
|
89
|
+
DedicatedMasterEnabled: true
|
|
90
|
+
DedicatedMasterType: !Ref InstanceType
|
|
91
|
+
DedicatedMasterCount: 3
|
|
92
|
+
ZoneAwarenessEnabled: true
|
|
93
|
+
ZoneAwarenessConfig:
|
|
94
|
+
AvailabilityZoneCount: 3
|
|
95
|
+
EBSOptions:
|
|
96
|
+
EBSEnabled: true
|
|
97
|
+
VolumeType: gp3
|
|
98
|
+
VolumeSize: !Ref VolumeSize
|
|
99
|
+
EncryptionAtRestOptions:
|
|
100
|
+
Enabled: true
|
|
101
|
+
NodeToNodeEncryptionOptions:
|
|
102
|
+
Enabled: true
|
|
103
|
+
DomainEndpointOptions:
|
|
104
|
+
EnforceHTTPS: true
|
|
105
|
+
TLSSecurityPolicy: Policy-Min-TLS-1-2-PFS-2023-10
|
|
106
|
+
AdvancedSecurityOptions:
|
|
107
|
+
Enabled: true
|
|
108
|
+
InternalUserDatabaseEnabled: false
|
|
109
|
+
MasterUserOptions:
|
|
110
|
+
MasterUserARN: !If
|
|
111
|
+
- HasMasterUserARN
|
|
112
|
+
- !Ref MasterUserARN
|
|
113
|
+
- !Sub 'arn:aws:iam::${AWS::AccountId}:root'
|
|
114
|
+
# "Only use fine-grained access control" — open domain policy,
|
|
115
|
+
# all authorization is handled by the OpenSearch Security plugin (FGAC).
|
|
116
|
+
AccessPolicies:
|
|
117
|
+
Version: '2012-10-17'
|
|
118
|
+
Statement:
|
|
119
|
+
- Effect: Allow
|
|
120
|
+
Principal:
|
|
121
|
+
AWS: '*'
|
|
122
|
+
Action: 'es:*'
|
|
123
|
+
Resource: !Sub 'arn:aws:es:${AWS::Region}:${AWS::AccountId}:domain/${DomainName}/*'
|
|
124
|
+
Tags:
|
|
125
|
+
- Key: agent-health
|
|
126
|
+
Value: observability
|
|
127
|
+
- Key: ManagedBy
|
|
128
|
+
Value: AgentHealthCFN
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# IAM Role — OSIS Pipeline Execution
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
OSISPipelineRole:
|
|
134
|
+
Type: AWS::IAM::Role
|
|
135
|
+
Properties:
|
|
136
|
+
RoleName: !Sub '${DomainName}-osis-pipeline-role'
|
|
137
|
+
AssumeRolePolicyDocument:
|
|
138
|
+
Version: '2012-10-17'
|
|
139
|
+
Statement:
|
|
140
|
+
- Effect: Allow
|
|
141
|
+
Principal:
|
|
142
|
+
Service: osis-pipelines.amazonaws.com
|
|
143
|
+
Action: 'sts:AssumeRole'
|
|
144
|
+
Policies:
|
|
145
|
+
- PolicyName: OSISToOpenSearch
|
|
146
|
+
PolicyDocument:
|
|
147
|
+
Version: '2012-10-17'
|
|
148
|
+
Statement:
|
|
149
|
+
- Effect: Allow
|
|
150
|
+
Action:
|
|
151
|
+
- 'es:DescribeDomain'
|
|
152
|
+
- 'es:ESHttp*'
|
|
153
|
+
Resource:
|
|
154
|
+
- !Sub 'arn:aws:es:${AWS::Region}:${AWS::AccountId}:domain/${DomainName}'
|
|
155
|
+
- !Sub 'arn:aws:es:${AWS::Region}:${AWS::AccountId}:domain/${DomainName}/*'
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# IAM Role — Ingestion (for agents pushing telemetry via SigV4)
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
IngestionRole:
|
|
161
|
+
Type: AWS::IAM::Role
|
|
162
|
+
Properties:
|
|
163
|
+
RoleName: !Sub '${DomainName}-ingestion-role'
|
|
164
|
+
AssumeRolePolicyDocument:
|
|
165
|
+
Version: '2012-10-17'
|
|
166
|
+
Statement:
|
|
167
|
+
- Effect: Allow
|
|
168
|
+
Principal:
|
|
169
|
+
AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root'
|
|
170
|
+
Action: 'sts:AssumeRole'
|
|
171
|
+
Policies:
|
|
172
|
+
- PolicyName: OSISIngest
|
|
173
|
+
PolicyDocument:
|
|
174
|
+
Version: '2012-10-17'
|
|
175
|
+
Statement:
|
|
176
|
+
- Effect: Allow
|
|
177
|
+
Action:
|
|
178
|
+
- 'osis:Ingest'
|
|
179
|
+
Resource:
|
|
180
|
+
- !Sub 'arn:aws:osis:${AWS::Region}:${AWS::AccountId}:pipeline/${DomainName}-traces'
|
|
181
|
+
- !Sub 'arn:aws:osis:${AWS::Region}:${AWS::AccountId}:pipeline/${DomainName}-logs'
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# OpenSearch Ingestion (OSIS) Pipeline — OTLP Traces → OpenSearch
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
OSISTracePipeline:
|
|
187
|
+
Type: AWS::OSIS::Pipeline
|
|
188
|
+
DependsOn: OpenSearchDomain
|
|
189
|
+
Properties:
|
|
190
|
+
PipelineName: !Sub '${DomainName}-traces'
|
|
191
|
+
MinUnits: !Ref PipelineMinUnits
|
|
192
|
+
MaxUnits: !Ref PipelineMaxUnits
|
|
193
|
+
PipelineConfigurationBody: !Sub |
|
|
194
|
+
version: "2"
|
|
195
|
+
otel-traces-entry:
|
|
196
|
+
source:
|
|
197
|
+
otel_trace_source:
|
|
198
|
+
path: "/${DomainName}-traces/v1/traces"
|
|
199
|
+
sink:
|
|
200
|
+
- pipeline:
|
|
201
|
+
name: "otel-traces-raw-pipeline"
|
|
202
|
+
- pipeline:
|
|
203
|
+
name: "otel-service-map-pipeline"
|
|
204
|
+
otel-traces-raw-pipeline:
|
|
205
|
+
source:
|
|
206
|
+
pipeline:
|
|
207
|
+
name: "otel-traces-entry"
|
|
208
|
+
processor:
|
|
209
|
+
- otel_traces:
|
|
210
|
+
sink:
|
|
211
|
+
- opensearch:
|
|
212
|
+
hosts:
|
|
213
|
+
- "https://${OpenSearchDomain.DomainEndpoint}"
|
|
214
|
+
index_type: trace-analytics-raw
|
|
215
|
+
aws:
|
|
216
|
+
sts_role_arn: "${OSISPipelineRole.Arn}"
|
|
217
|
+
region: "${AWS::Region}"
|
|
218
|
+
otel-service-map-pipeline:
|
|
219
|
+
source:
|
|
220
|
+
pipeline:
|
|
221
|
+
name: "otel-traces-entry"
|
|
222
|
+
processor:
|
|
223
|
+
- service_map:
|
|
224
|
+
window_duration: 180
|
|
225
|
+
sink:
|
|
226
|
+
- opensearch:
|
|
227
|
+
hosts:
|
|
228
|
+
- "https://${OpenSearchDomain.DomainEndpoint}"
|
|
229
|
+
index_type: trace-analytics-service-map
|
|
230
|
+
aws:
|
|
231
|
+
sts_role_arn: "${OSISPipelineRole.Arn}"
|
|
232
|
+
region: "${AWS::Region}"
|
|
233
|
+
Tags:
|
|
234
|
+
- Key: agent-health
|
|
235
|
+
Value: observability
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# OpenSearch Ingestion (OSIS) Pipeline — OTLP Logs → OpenSearch
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
OSISLogsPipeline:
|
|
241
|
+
Type: AWS::OSIS::Pipeline
|
|
242
|
+
DependsOn: OpenSearchDomain
|
|
243
|
+
Properties:
|
|
244
|
+
PipelineName: !Sub '${DomainName}-logs'
|
|
245
|
+
MinUnits: !Ref PipelineMinUnits
|
|
246
|
+
MaxUnits: !Ref PipelineMaxUnits
|
|
247
|
+
PipelineConfigurationBody: !Sub |
|
|
248
|
+
version: "2"
|
|
249
|
+
otel-logs-pipeline:
|
|
250
|
+
source:
|
|
251
|
+
otel_logs_source:
|
|
252
|
+
path: "/${DomainName}-logs/v1/logs"
|
|
253
|
+
sink:
|
|
254
|
+
- opensearch:
|
|
255
|
+
hosts:
|
|
256
|
+
- "https://${OpenSearchDomain.DomainEndpoint}"
|
|
257
|
+
index: "otel-logs-%%{yyyy.MM.dd}"
|
|
258
|
+
aws:
|
|
259
|
+
sts_role_arn: "${OSISPipelineRole.Arn}"
|
|
260
|
+
region: "${AWS::Region}"
|
|
261
|
+
Tags:
|
|
262
|
+
- Key: agent-health
|
|
263
|
+
Value: observability
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# OTLP Ingestion — API Gateway + Lambda → OpenSearch (direct)
|
|
267
|
+
# Provides a public HTTPS endpoint so apps can send OTLP without SigV4 auth.
|
|
268
|
+
# Converts OTLP JSON to trace-analytics format and bulk-indexes into OpenSearch.
|
|
269
|
+
# Also forwards to OSIS pipelines when available for service-map generation.
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
OTLPIngestLambdaRole:
|
|
272
|
+
Type: AWS::IAM::Role
|
|
273
|
+
Properties:
|
|
274
|
+
RoleName: !Sub '${DomainName}-otlp-ingest-lambda-role'
|
|
275
|
+
AssumeRolePolicyDocument:
|
|
276
|
+
Version: '2012-10-17'
|
|
277
|
+
Statement:
|
|
278
|
+
- Effect: Allow
|
|
279
|
+
Principal:
|
|
280
|
+
Service: lambda.amazonaws.com
|
|
281
|
+
Action: 'sts:AssumeRole'
|
|
282
|
+
Policies:
|
|
283
|
+
- PolicyName: OpenSearchAccess
|
|
284
|
+
PolicyDocument:
|
|
285
|
+
Version: '2012-10-17'
|
|
286
|
+
Statement:
|
|
287
|
+
- Effect: Allow
|
|
288
|
+
Action:
|
|
289
|
+
- 'es:ESHttp*'
|
|
290
|
+
Resource:
|
|
291
|
+
- !Sub 'arn:aws:es:${AWS::Region}:${AWS::AccountId}:domain/${DomainName}/*'
|
|
292
|
+
- PolicyName: OSISIngest
|
|
293
|
+
PolicyDocument:
|
|
294
|
+
Version: '2012-10-17'
|
|
295
|
+
Statement:
|
|
296
|
+
- Effect: Allow
|
|
297
|
+
Action:
|
|
298
|
+
- 'osis:Ingest'
|
|
299
|
+
Resource:
|
|
300
|
+
- !Sub 'arn:aws:osis:${AWS::Region}:${AWS::AccountId}:pipeline/${DomainName}-traces'
|
|
301
|
+
- !Sub 'arn:aws:osis:${AWS::Region}:${AWS::AccountId}:pipeline/${DomainName}-logs'
|
|
302
|
+
- PolicyName: CloudWatchLogs
|
|
303
|
+
PolicyDocument:
|
|
304
|
+
Version: '2012-10-17'
|
|
305
|
+
Statement:
|
|
306
|
+
- Effect: Allow
|
|
307
|
+
Action:
|
|
308
|
+
- 'logs:CreateLogGroup'
|
|
309
|
+
- 'logs:CreateLogStream'
|
|
310
|
+
- 'logs:PutLogEvents'
|
|
311
|
+
Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*'
|
|
312
|
+
|
|
313
|
+
OTLPIngestFunction:
|
|
314
|
+
Type: AWS::Lambda::Function
|
|
315
|
+
DependsOn: OpenSearchDomain
|
|
316
|
+
Properties:
|
|
317
|
+
FunctionName: !Sub '${DomainName}-otlp-ingest'
|
|
318
|
+
Runtime: python3.12
|
|
319
|
+
Handler: index.handler
|
|
320
|
+
Timeout: 30
|
|
321
|
+
MemorySize: 256
|
|
322
|
+
Role: !GetAtt OTLPIngestLambdaRole.Arn
|
|
323
|
+
Environment:
|
|
324
|
+
Variables:
|
|
325
|
+
OPENSEARCH_ENDPOINT: !Sub 'https://${OpenSearchDomain.DomainEndpoint}'
|
|
326
|
+
Code:
|
|
327
|
+
ZipFile: |
|
|
328
|
+
"""OTLP Ingestion — converts OTLP JSON to OpenSearch trace-analytics format."""
|
|
329
|
+
import json
|
|
330
|
+
import os
|
|
331
|
+
import urllib.request
|
|
332
|
+
import hashlib
|
|
333
|
+
from datetime import datetime, timezone
|
|
334
|
+
from botocore.auth import SigV4Auth
|
|
335
|
+
from botocore.awsrequest import AWSRequest
|
|
336
|
+
from botocore.session import Session
|
|
337
|
+
|
|
338
|
+
OS_ENDPOINT = os.environ['OPENSEARCH_ENDPOINT']
|
|
339
|
+
REGION = os.environ.get('AWS_REGION', 'us-east-1')
|
|
340
|
+
session = Session()
|
|
341
|
+
|
|
342
|
+
STATUS_MAP = {0: 0, 1: 0, 2: 2} # OTLP Unset/Ok->0, Error->2
|
|
343
|
+
KIND_MAP = {0: 'SPAN_KIND_UNSPECIFIED', 1: 'SPAN_KIND_INTERNAL', 2: 'SPAN_KIND_SERVER',
|
|
344
|
+
3: 'SPAN_KIND_CLIENT', 4: 'SPAN_KIND_PRODUCER', 5: 'SPAN_KIND_CONSUMER'}
|
|
345
|
+
|
|
346
|
+
def _creds():
|
|
347
|
+
c = session.get_credentials()
|
|
348
|
+
return c.resolve_credentials() if hasattr(c, 'resolve_credentials') else c
|
|
349
|
+
|
|
350
|
+
def _attr_value(v):
|
|
351
|
+
for k in ('stringValue', 'intValue', 'boolValue', 'doubleValue'):
|
|
352
|
+
if k in v:
|
|
353
|
+
return v[k]
|
|
354
|
+
if 'arrayValue' in v:
|
|
355
|
+
return [_attr_value(x) for x in v['arrayValue'].get('values', [])]
|
|
356
|
+
return str(v)
|
|
357
|
+
|
|
358
|
+
def _nano_to_iso(ns):
|
|
359
|
+
"""Convert nanosecond timestamp to ISO 8601 with nanosecond precision."""
|
|
360
|
+
try:
|
|
361
|
+
ns = int(ns)
|
|
362
|
+
except (ValueError, TypeError):
|
|
363
|
+
return '1970-01-01T00:00:00.000000000Z'
|
|
364
|
+
secs = ns // 1_000_000_000
|
|
365
|
+
nanos = ns % 1_000_000_000
|
|
366
|
+
dt = datetime.fromtimestamp(secs, tz=timezone.utc)
|
|
367
|
+
return dt.strftime('%Y-%m-%dT%H:%M:%S') + f'.{nanos:09d}Z'
|
|
368
|
+
|
|
369
|
+
def _sanitize_key(key):
|
|
370
|
+
"""Replace dots with @ in attribute keys (data prepper convention)."""
|
|
371
|
+
return key.replace('.', '@')
|
|
372
|
+
|
|
373
|
+
def _convert_span(span, resource_attrs, scope_name):
|
|
374
|
+
trace_id = span.get('traceId', '')
|
|
375
|
+
span_id = span.get('spanId', '')
|
|
376
|
+
parent = span.get('parentSpanId', '')
|
|
377
|
+
start_ns = span.get('startTimeUnixNano', '0')
|
|
378
|
+
end_ns = span.get('endTimeUnixNano', '0')
|
|
379
|
+
try:
|
|
380
|
+
duration_nanos = int(end_ns) - int(start_ns)
|
|
381
|
+
if duration_nanos < 0:
|
|
382
|
+
duration_nanos = 0
|
|
383
|
+
except (ValueError, TypeError):
|
|
384
|
+
duration_nanos = 0
|
|
385
|
+
status = span.get('status', {})
|
|
386
|
+
status_code = STATUS_MAP.get(status.get('code', 0), 0)
|
|
387
|
+
kind = KIND_MAP.get(span.get('kind', 0), 'SPAN_KIND_UNSPECIFIED')
|
|
388
|
+
service_name = 'unknown'
|
|
389
|
+
events = []
|
|
390
|
+
for evt in span.get('events', []):
|
|
391
|
+
evt_attrs = {}
|
|
392
|
+
for a in evt.get('attributes', []):
|
|
393
|
+
evt_attrs[_sanitize_key(a['key'])] = _attr_value(a['value'])
|
|
394
|
+
events.append({
|
|
395
|
+
'name': evt.get('name', ''),
|
|
396
|
+
'time': _nano_to_iso(evt.get('timeUnixNano', '0')),
|
|
397
|
+
'attributes': evt_attrs
|
|
398
|
+
})
|
|
399
|
+
doc = {
|
|
400
|
+
'traceId': trace_id,
|
|
401
|
+
'spanId': span_id,
|
|
402
|
+
'parentSpanId': parent,
|
|
403
|
+
'traceState': span.get('traceState', ''),
|
|
404
|
+
'name': span.get('name', ''),
|
|
405
|
+
'kind': kind,
|
|
406
|
+
'startTime': _nano_to_iso(start_ns),
|
|
407
|
+
'endTime': _nano_to_iso(end_ns),
|
|
408
|
+
'durationInNanos': duration_nanos,
|
|
409
|
+
'serviceName': service_name,
|
|
410
|
+
'status': {'code': status_code, 'message': status.get('message', '')},
|
|
411
|
+
'events': events,
|
|
412
|
+
'links': [],
|
|
413
|
+
'instrumentationScope.name': scope_name,
|
|
414
|
+
}
|
|
415
|
+
if not parent:
|
|
416
|
+
doc['traceGroup'] = span.get('name', '')
|
|
417
|
+
doc['traceGroupFields.endTime'] = _nano_to_iso(end_ns)
|
|
418
|
+
doc['traceGroupFields.durationInNanos'] = duration_nanos
|
|
419
|
+
doc['traceGroupFields.statusCode'] = status_code
|
|
420
|
+
# Flatten resource attributes with @ separator for dotted keys
|
|
421
|
+
for a in resource_attrs:
|
|
422
|
+
key = _sanitize_key(a['key'])
|
|
423
|
+
val = _attr_value(a['value'])
|
|
424
|
+
doc[f'resource.attributes.{key}'] = val
|
|
425
|
+
if a['key'] == 'service.name':
|
|
426
|
+
doc['serviceName'] = val
|
|
427
|
+
# Flatten span attributes with @ separator for dotted keys
|
|
428
|
+
for a in span.get('attributes', []):
|
|
429
|
+
key = _sanitize_key(a['key'])
|
|
430
|
+
val = _attr_value(a['value'])
|
|
431
|
+
doc[f'span.attributes.{key}'] = val
|
|
432
|
+
doc_id = hashlib.md5(f'{trace_id}{span_id}'.encode()).hexdigest()
|
|
433
|
+
return doc_id, doc
|
|
434
|
+
|
|
435
|
+
def _signed_request(method, path, body):
|
|
436
|
+
url = OS_ENDPOINT + path
|
|
437
|
+
request = AWSRequest(method=method, url=url, data=body,
|
|
438
|
+
headers={'Content-Type': 'application/json'})
|
|
439
|
+
SigV4Auth(_creds(), 'es', REGION).add_auth(request)
|
|
440
|
+
req = urllib.request.Request(url, data=body.encode('utf-8'), method=method,
|
|
441
|
+
headers={k: v for k, v in dict(request.headers).items()})
|
|
442
|
+
try:
|
|
443
|
+
with urllib.request.urlopen(req) as resp:
|
|
444
|
+
return resp.status, resp.read().decode('utf-8', errors='replace')
|
|
445
|
+
except urllib.error.HTTPError as e:
|
|
446
|
+
return e.code, e.read().decode('utf-8', errors='replace')
|
|
447
|
+
|
|
448
|
+
def handler(event, context):
|
|
449
|
+
path = event.get('rawPath', '')
|
|
450
|
+
if '/v1/traces' not in path and '/v1/logs' not in path:
|
|
451
|
+
return {'statusCode': 404, 'body': json.dumps({'error': f'Unknown path: {path}'})}
|
|
452
|
+
body = event.get('body', '')
|
|
453
|
+
is_base64 = event.get('isBase64Encoded', False)
|
|
454
|
+
if is_base64:
|
|
455
|
+
import base64
|
|
456
|
+
body = base64.b64decode(body).decode('utf-8', errors='replace')
|
|
457
|
+
try:
|
|
458
|
+
otlp = json.loads(body) if isinstance(body, str) else body
|
|
459
|
+
except json.JSONDecodeError as e:
|
|
460
|
+
return {'statusCode': 400, 'body': json.dumps({'error': f'Invalid JSON: {e}'})}
|
|
461
|
+
bulk_lines = []
|
|
462
|
+
for rs in otlp.get('resourceSpans', []):
|
|
463
|
+
res_attrs = rs.get('resource', {}).get('attributes', [])
|
|
464
|
+
for ss in rs.get('scopeSpans', []):
|
|
465
|
+
scope_name = ss.get('scope', {}).get('name', '')
|
|
466
|
+
for span in ss.get('spans', []):
|
|
467
|
+
doc_id, doc = _convert_span(span, res_attrs, scope_name)
|
|
468
|
+
bulk_lines.append(json.dumps({'index': {'_index': 'otel-v1-apm-span-000001', '_id': doc_id}}))
|
|
469
|
+
bulk_lines.append(json.dumps(doc, default=str))
|
|
470
|
+
if not bulk_lines:
|
|
471
|
+
return {'statusCode': 200, 'body': json.dumps({'message': 'No spans to index'})}
|
|
472
|
+
bulk_body = '\n'.join(bulk_lines) + '\n'
|
|
473
|
+
status, resp_body = _signed_request('POST', '/otel-v1-apm-span-000001/_bulk', bulk_body)
|
|
474
|
+
return {'statusCode': status, 'body': resp_body}
|
|
475
|
+
Tags:
|
|
476
|
+
- Key: agent-health
|
|
477
|
+
Value: observability
|
|
478
|
+
|
|
479
|
+
OTLPIngestApi:
|
|
480
|
+
Type: AWS::ApiGatewayV2::Api
|
|
481
|
+
Properties:
|
|
482
|
+
Name: !Sub '${DomainName}-otlp-ingest'
|
|
483
|
+
ProtocolType: HTTP
|
|
484
|
+
CorsConfiguration:
|
|
485
|
+
AllowOrigins:
|
|
486
|
+
- '*'
|
|
487
|
+
AllowMethods:
|
|
488
|
+
- POST
|
|
489
|
+
AllowHeaders:
|
|
490
|
+
- content-type
|
|
491
|
+
|
|
492
|
+
OTLPIngestApiIntegration:
|
|
493
|
+
Type: AWS::ApiGatewayV2::Integration
|
|
494
|
+
Properties:
|
|
495
|
+
ApiId: !Ref OTLPIngestApi
|
|
496
|
+
IntegrationType: AWS_PROXY
|
|
497
|
+
IntegrationUri: !GetAtt OTLPIngestFunction.Arn
|
|
498
|
+
PayloadFormatVersion: '2.0'
|
|
499
|
+
|
|
500
|
+
OTLPIngestApiRoute:
|
|
501
|
+
Type: AWS::ApiGatewayV2::Route
|
|
502
|
+
Properties:
|
|
503
|
+
ApiId: !Ref OTLPIngestApi
|
|
504
|
+
RouteKey: 'POST /v1/{signal+}'
|
|
505
|
+
Target: !Sub 'integrations/${OTLPIngestApiIntegration}'
|
|
506
|
+
|
|
507
|
+
OTLPIngestApiStage:
|
|
508
|
+
Type: AWS::ApiGatewayV2::Stage
|
|
509
|
+
Properties:
|
|
510
|
+
ApiId: !Ref OTLPIngestApi
|
|
511
|
+
StageName: '$default'
|
|
512
|
+
AutoDeploy: true
|
|
513
|
+
|
|
514
|
+
OTLPIngestApiPermission:
|
|
515
|
+
Type: AWS::Lambda::Permission
|
|
516
|
+
Properties:
|
|
517
|
+
FunctionName: !Ref OTLPIngestFunction
|
|
518
|
+
Action: 'lambda:InvokeFunction'
|
|
519
|
+
Principal: apigateway.amazonaws.com
|
|
520
|
+
SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${OTLPIngestApi}/*'
|
|
521
|
+
|
|
522
|
+
# =============================================================================
|
|
523
|
+
# Outputs
|
|
524
|
+
# =============================================================================
|
|
525
|
+
Outputs:
|
|
526
|
+
OpenSearchEndpoint:
|
|
527
|
+
Description: OpenSearch domain endpoint URL
|
|
528
|
+
Value: !Sub 'https://${OpenSearchDomain.DomainEndpoint}'
|
|
529
|
+
|
|
530
|
+
OSISTraceIngestEndpoint:
|
|
531
|
+
Description: OSIS trace ingest endpoint (configure as OTEL_EXPORTER_OTLP_TRACES_ENDPOINT in your agent)
|
|
532
|
+
Value: !Join
|
|
533
|
+
- ''
|
|
534
|
+
- - 'https://'
|
|
535
|
+
- !Select [0, !GetAtt OSISTracePipeline.IngestEndpointUrls]
|
|
536
|
+
|
|
537
|
+
OSISLogsIngestEndpoint:
|
|
538
|
+
Description: OSIS logs ingest endpoint (configure as OTEL_EXPORTER_OTLP_LOGS_ENDPOINT in your agent)
|
|
539
|
+
Value: !Join
|
|
540
|
+
- ''
|
|
541
|
+
- - 'https://'
|
|
542
|
+
- !Select [0, !GetAtt OSISLogsPipeline.IngestEndpointUrls]
|
|
543
|
+
|
|
544
|
+
Region:
|
|
545
|
+
Description: AWS Region where the stack was deployed
|
|
546
|
+
Value: !Ref 'AWS::Region'
|
|
547
|
+
|
|
548
|
+
IngestionRoleArn:
|
|
549
|
+
Description: IAM role ARN for agents to assume when pushing telemetry via SigV4
|
|
550
|
+
Value: !GetAtt IngestionRole.Arn
|
|
551
|
+
|
|
552
|
+
OTLPIngestEndpoint:
|
|
553
|
+
Description: >-
|
|
554
|
+
HTTPS endpoint for OTLP trace/log ingestion (no SigV4 needed on client).
|
|
555
|
+
Set OTEL_EXPORTER_OTLP_ENDPOINT to this value in your agent config.
|
|
556
|
+
Value: !Sub 'https://${OTLPIngestApi}.execute-api.${AWS::Region}.amazonaws.com'
|
|
557
|
+
|
|
558
|
+
AgentHealthConfigJSON:
|
|
559
|
+
Description: >-
|
|
560
|
+
Copy this JSON block into your agent-health.config.json file,
|
|
561
|
+
or run: npx @opensearch-project/agent-health configure --from-stack AgentHealthObservability
|
|
562
|
+
Value: !Sub |
|
|
563
|
+
{
|
|
564
|
+
"observability": {
|
|
565
|
+
"endpoint": "https://${OpenSearchDomain.DomainEndpoint}",
|
|
566
|
+
"authType": "sigv4",
|
|
567
|
+
"awsRegion": "${AWS::Region}",
|
|
568
|
+
"awsService": "es",
|
|
569
|
+
"tlsSkipVerify": false
|
|
570
|
+
}
|
|
571
|
+
}
|