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

1"""CloudFormation custom resource that turns on CloudWatch Transaction Search. 

2 

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. 

9 

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: 

13 

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. 

23 

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. 

27 

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""" 

32 

33from __future__ import annotations 

34 

35import json 

36import logging 

37from typing import Any 

38 

39import boto3 

40from botocore.config import Config 

41from botocore.exceptions import BotoCoreError, ClientError 

42 

43logger = logging.getLogger() 

44logger.setLevel(logging.INFO) 

45 

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" 

50 

51#: X-Ray trace segment destination that means "Transaction Search is on". 

52CLOUDWATCH_LOGS_DESTINATION = "CloudWatchLogs" 

53 

54#: The cdk.json knob an operator flips to leave the setting unmanaged. 

55OPT_OUT_SETTING = "tracing.enable_transaction_search" 

56 

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) 

64 

65#: ResourceProperties the regional stack must send on Create/Update. 

66_REQUIRED_PROPERTIES = ("Region", "AccountId", "Partition") 

67 

68 

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. 

71 

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=(",", ":")) 

99 

100 

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. 

110 

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"} 

128 

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 } 

149 

150 

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 

160 

161 

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 ) 

171 

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}") 

181 

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}