Coverage for lambda / transaction-search / handler.py: 100.00%
55 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-28 16:19 +0000
1"""CloudFormation custom resource that turns on CloudWatch Transaction Search.
3GCO's API services export OpenTelemetry spans straight to the X-Ray OTLP
4endpoint (``https://xray.<region>.amazonaws.com/v1/traces``), and that endpoint
5only accepts spans once CloudWatch Transaction Search is on in the Region. Each
6regional stack therefore carries one of these resources (behind a CDK
7``cr.Provider``) while ``tracing.enabled`` and
8``tracing.enable_transaction_search`` are both true.
10Transaction Search is an account-level setting, configured per Region and
11shared with every other traced workload in the account, so the resource is
12strictly additive:
14* **Create/Update** read the X-Ray trace segment destination. When it already
15 is ``CloudWatchLogs`` (``ACTIVE``, or ``PENDING`` while X-Ray finishes a
16 switch someone else started) nothing is changed. Otherwise the handler writes
17 the CloudWatch Logs resource policy that lets X-Ray deliver spans into the
18 ``aws/spans`` and ``/aws/application-signals/data`` log groups, then switches
19 the destination to ``CloudWatchLogs``.
20* **Delete** is a no-op. Other workloads may rely on Transaction Search, and
21 switching it off changes how every X-Ray trace in the Region is stored and
22 billed, so GCO never disables it and never removes the policy.
24CloudFormation's native ``AWS::XRay::TransactionSearchConfig`` is not used: it
25can only be created while Transaction Search is off, and it would tie a shared
26account-level setting to one stack's lifecycle.
28Failures raise, which the provider framework reports as a FAILED resource. The
29message names the ``tracing.enable_transaction_search`` opt-out for
30organizations that manage the setting themselves.
31"""
33from __future__ import annotations
35import json
36import logging
37from typing import Any
39import boto3
40from botocore.config import Config
41from botocore.exceptions import BotoCoreError, ClientError
43logger = logging.getLogger()
44logger.setLevel(logging.INFO)
46#: CloudWatch Logs resource policy GCO writes (account-level, per Region).
47#: Deliberately never deleted; the live release harness restores its own
48#: baseline instead.
49POLICY_NAME = "gco-transaction-search-xray-access"
51#: X-Ray trace segment destination that means "Transaction Search is on".
52CLOUDWATCH_LOGS_DESTINATION = "CloudWatchLogs"
54#: The cdk.json knob an operator flips to leave the setting unmanaged.
55OPT_OUT_SETTING = "tracing.enable_transaction_search"
57#: Bounded, standard-mode retries: X-Ray and CloudWatch Logs throttle
58#: configuration APIs, and a throttled deploy should retry, not fail.
59_CLIENT_CONFIG = Config(
60 connect_timeout=5,
61 read_timeout=20,
62 retries={"max_attempts": 5, "mode": "standard"},
63)
65#: ResourceProperties the regional stack must send on Create/Update.
66_REQUIRED_PROPERTIES = ("Region", "AccountId", "Partition")
69def transaction_search_resource_policy(*, partition: str, region: str, account: str) -> str:
70 """Render the resource policy that lets X-Ray write spans into CloudWatch Logs.
72 Mirrors the policy in the AWS "Enable transaction search" guide: only the
73 ``xray.amazonaws.com`` principal, only ``logs:PutLogEvents``, only the two
74 Transaction Search log groups of this Region, and only on behalf of this
75 account's X-Ray (``aws:SourceArn`` / ``aws:SourceAccount``), so the policy
76 cannot be used as a confused deputy by another account.
77 """
78 log_group_prefix = f"arn:{partition}:logs:{region}:{account}:log-group:"
79 document = {
80 "Version": "2012-10-17",
81 "Statement": [
82 {
83 "Sid": "TransactionSearchXRayAccess",
84 "Effect": "Allow",
85 "Principal": {"Service": "xray.amazonaws.com"},
86 "Action": "logs:PutLogEvents",
87 "Resource": [
88 f"{log_group_prefix}aws/spans:*",
89 f"{log_group_prefix}/aws/application-signals/data:*",
90 ],
91 "Condition": {
92 "ArnLike": {"aws:SourceArn": f"arn:{partition}:xray:{region}:{account}:*"},
93 "StringEquals": {"aws:SourceAccount": account},
94 },
95 }
96 ],
97 }
98 return json.dumps(document, separators=(",", ":"))
101def ensure_transaction_search(
102 xray: Any,
103 logs: Any,
104 *,
105 partition: str,
106 region: str,
107 account: str,
108) -> dict[str, str]:
109 """Enable Transaction Search unless it is already on, and report the result.
111 Returns the custom-resource ``Data``: the trace segment ``Destination`` and
112 ``Status`` after the call, and ``Changed`` (``"true"``/``"false"``) —
113 whether this invocation switched the destination. Values are strings
114 because CloudFormation exposes custom-resource data through ``Fn::GetAtt``
115 as strings.
116 """
117 current = xray.get_trace_segment_destination()
118 destination = str(current.get("Destination", ""))
119 status = str(current.get("Status", ""))
120 if destination == CLOUDWATCH_LOGS_DESTINATION:
121 logger.info(
122 "Transaction Search already routes spans to CloudWatch Logs in %s (%s); "
123 "leaving it unchanged",
124 region,
125 status,
126 )
127 return {"Destination": destination, "Status": status, "Changed": "false"}
129 logger.info(
130 "Enabling Transaction Search in %s (current destination %s, %s)",
131 region,
132 destination or "unknown",
133 status or "unknown",
134 )
135 # Policy first, as in the AWS procedure: without it X-Ray has no
136 # permission to deliver spans into the Transaction Search log groups.
137 logs.put_resource_policy(
138 policyName=POLICY_NAME,
139 policyDocument=transaction_search_resource_policy(
140 partition=partition, region=region, account=account
141 ),
142 )
143 updated = xray.update_trace_segment_destination(Destination=CLOUDWATCH_LOGS_DESTINATION)
144 return {
145 "Destination": str(updated.get("Destination", CLOUDWATCH_LOGS_DESTINATION)),
146 "Status": str(updated.get("Status", "")),
147 "Changed": "true",
148 }
151def _required_properties(properties: dict[str, Any]) -> dict[str, str]:
152 """Return the non-empty string properties Create/Update need, or raise."""
153 values: dict[str, str] = {}
154 for name in _REQUIRED_PROPERTIES:
155 value = properties.get(name)
156 if not isinstance(value, str) or not value.strip():
157 raise ValueError(f"Transaction Search resource property {name} must be a string")
158 values[name] = value.strip()
159 return values
162def lambda_handler(event: dict[str, Any], _context: Any = None) -> dict[str, Any]:
163 """``cr.Provider`` on-event entrypoint: enable on Create/Update, keep on Delete."""
164 request_type = event.get("RequestType")
165 properties = event.get("ResourceProperties") or {}
166 project_name = str(properties.get("ProjectName") or "gco")
167 physical_id = str(
168 event.get("PhysicalResourceId")
169 or f"{project_name}-transaction-search-{properties.get('Region', 'unknown')}"
170 )
172 if request_type == "Delete":
173 logger.info(
174 "Delete requested; leaving Transaction Search and the %s policy in place "
175 "because other workloads in the account may depend on them",
176 POLICY_NAME,
177 )
178 return {"PhysicalResourceId": physical_id}
179 if request_type not in ("Create", "Update"):
180 raise ValueError(f"Unsupported RequestType {request_type!r}")
182 values = _required_properties(properties)
183 region = values["Region"]
184 try:
185 data = ensure_transaction_search(
186 boto3.client("xray", region_name=region, config=_CLIENT_CONFIG),
187 boto3.client("logs", region_name=region, config=_CLIENT_CONFIG),
188 partition=values["Partition"],
189 region=region,
190 account=values["AccountId"],
191 )
192 except (BotoCoreError, ClientError) as exc:
193 raise RuntimeError(
194 f"Could not enable CloudWatch Transaction Search in {region}: {exc}. "
195 "GCO's traces need it; enable it in the CloudWatch console "
196 "(Application Signals > Transaction Search), or set "
197 f"{OPT_OUT_SETTING} to false in cdk.json if your organization "
198 "manages it."
199 ) from exc
200 logger.info("Transaction Search in %s: %s", region, data)
201 return {"PhysicalResourceId": physical_id, "Data": data}