airbyte-agent-greenhouse 0.17.48__py3-none-any.whl
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.
- airbyte_agent_greenhouse/__init__.py +105 -0
- airbyte_agent_greenhouse/_vendored/__init__.py +1 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/__init__.py +82 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/auth_strategies.py +1120 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/auth_template.py +135 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/cloud_utils/__init__.py +5 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/cloud_utils/client.py +213 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/connector_model_loader.py +965 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/constants.py +78 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/exceptions.py +23 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/executor/__init__.py +31 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/executor/hosted_executor.py +196 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/executor/local_executor.py +1724 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/executor/models.py +190 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/extensions.py +693 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/http/__init__.py +37 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/http/config.py +98 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/http/exceptions.py +119 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/http/protocols.py +114 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/http/response.py +104 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/http_client.py +693 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/introspection.py +262 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/logging/__init__.py +11 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/logging/logger.py +273 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/logging/types.py +93 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/observability/__init__.py +11 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/observability/config.py +179 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/observability/models.py +19 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/observability/redactor.py +81 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/observability/session.py +103 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/performance/__init__.py +6 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/performance/instrumentation.py +57 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/performance/metrics.py +93 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/schema/__init__.py +75 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/schema/base.py +164 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/schema/components.py +239 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/schema/connector.py +120 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/schema/extensions.py +230 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/schema/operations.py +146 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/schema/security.py +223 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/secrets.py +182 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/telemetry/__init__.py +10 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/telemetry/config.py +32 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/telemetry/events.py +59 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/telemetry/tracker.py +155 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/types.py +245 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/utils.py +60 -0
- airbyte_agent_greenhouse/_vendored/connector_sdk/validation.py +828 -0
- airbyte_agent_greenhouse/connector.py +1391 -0
- airbyte_agent_greenhouse/connector_model.py +2356 -0
- airbyte_agent_greenhouse/models.py +281 -0
- airbyte_agent_greenhouse/types.py +136 -0
- airbyte_agent_greenhouse-0.17.48.dist-info/METADATA +116 -0
- airbyte_agent_greenhouse-0.17.48.dist-info/RECORD +57 -0
- airbyte_agent_greenhouse-0.17.48.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared introspection utilities for connector metadata.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for introspecting connector metadata,
|
|
5
|
+
generating descriptions, and formatting parameter signatures. These
|
|
6
|
+
functions are used by both the runtime decorators and the generated
|
|
7
|
+
connector code.
|
|
8
|
+
|
|
9
|
+
The module is designed to work with any object conforming to the
|
|
10
|
+
ConnectorModel and EndpointDefinition interfaces from connector_sdk.types.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
# Constants
|
|
18
|
+
MAX_EXAMPLE_QUESTIONS = 5 # Maximum number of example questions to include in description
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EndpointProtocol(Protocol):
|
|
22
|
+
"""Protocol defining the expected interface for endpoint parameters.
|
|
23
|
+
|
|
24
|
+
This allows functions to work with any endpoint-like object
|
|
25
|
+
that has these attributes, including EndpointDefinition and mock objects.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
path_params: list[str]
|
|
29
|
+
path_params_schema: dict[str, dict[str, Any]]
|
|
30
|
+
query_params: list[str]
|
|
31
|
+
query_params_schema: dict[str, dict[str, Any]]
|
|
32
|
+
body_fields: list[str]
|
|
33
|
+
request_schema: dict[str, Any] | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EntityProtocol(Protocol):
|
|
37
|
+
"""Protocol defining the expected interface for entity definitions."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
actions: list[Any]
|
|
41
|
+
endpoints: dict[Any, EndpointProtocol]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConnectorModelProtocol(Protocol):
|
|
45
|
+
"""Protocol defining the expected interface for connector model parameters.
|
|
46
|
+
|
|
47
|
+
This allows functions to work with any connector-like object
|
|
48
|
+
that has these attributes, including ConnectorModel and mock objects.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def entities(self) -> list[EntityProtocol]: ...
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def openapi_spec(self) -> Any: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_param_signature(endpoint: EndpointProtocol) -> str:
|
|
59
|
+
"""Format parameter signature for an endpoint action.
|
|
60
|
+
|
|
61
|
+
Returns a string like: (id*) or (limit?, starting_after?, email?)
|
|
62
|
+
where * = required, ? = optional
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
endpoint: Object conforming to EndpointProtocol (e.g., EndpointDefinition)
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Formatted parameter signature string
|
|
69
|
+
"""
|
|
70
|
+
params = []
|
|
71
|
+
|
|
72
|
+
# Defensive: safely access attributes with defaults for malformed endpoints
|
|
73
|
+
path_params = getattr(endpoint, "path_params", []) or []
|
|
74
|
+
query_params = getattr(endpoint, "query_params", []) or []
|
|
75
|
+
query_params_schema = getattr(endpoint, "query_params_schema", {}) or {}
|
|
76
|
+
body_fields = getattr(endpoint, "body_fields", []) or []
|
|
77
|
+
request_schema = getattr(endpoint, "request_schema", None)
|
|
78
|
+
|
|
79
|
+
# Path params (always required)
|
|
80
|
+
for name in path_params:
|
|
81
|
+
params.append(f"{name}*")
|
|
82
|
+
|
|
83
|
+
# Query params
|
|
84
|
+
for name in query_params:
|
|
85
|
+
schema = query_params_schema.get(name, {})
|
|
86
|
+
required = schema.get("required", False)
|
|
87
|
+
params.append(f"{name}{'*' if required else '?'}")
|
|
88
|
+
|
|
89
|
+
# Body fields
|
|
90
|
+
if request_schema:
|
|
91
|
+
required_fields = set(request_schema.get("required", []))
|
|
92
|
+
for name in body_fields:
|
|
93
|
+
params.append(f"{name}{'*' if name in required_fields else '?'}")
|
|
94
|
+
|
|
95
|
+
return f"({', '.join(params)})" if params else "()"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def describe_entities(model: ConnectorModelProtocol) -> list[dict[str, Any]]:
|
|
99
|
+
"""Generate entity descriptions from ConnectorModel.
|
|
100
|
+
|
|
101
|
+
Returns a list of entity descriptions with detailed parameter information
|
|
102
|
+
for each action. This is used by generated connectors' describe() method.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
model: Object conforming to ConnectorModelProtocol (e.g., ConnectorModel)
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of entity description dicts with keys:
|
|
109
|
+
- entity_name: Name of the entity (e.g., "contacts", "deals")
|
|
110
|
+
- description: Entity description from the first endpoint
|
|
111
|
+
- available_actions: List of actions (e.g., ["list", "get", "create"])
|
|
112
|
+
- parameters: Dict mapping action -> list of parameter dicts
|
|
113
|
+
"""
|
|
114
|
+
entities = []
|
|
115
|
+
for entity_def in model.entities:
|
|
116
|
+
description = ""
|
|
117
|
+
parameters: dict[str, list[dict[str, Any]]] = {}
|
|
118
|
+
|
|
119
|
+
endpoints = getattr(entity_def, "endpoints", {}) or {}
|
|
120
|
+
if endpoints:
|
|
121
|
+
for action, endpoint in endpoints.items():
|
|
122
|
+
# Get description from first endpoint that has one
|
|
123
|
+
if not description:
|
|
124
|
+
endpoint_desc = getattr(endpoint, "description", None)
|
|
125
|
+
if endpoint_desc:
|
|
126
|
+
description = endpoint_desc
|
|
127
|
+
|
|
128
|
+
action_params: list[dict[str, Any]] = []
|
|
129
|
+
|
|
130
|
+
# Defensive: safely access endpoint attributes
|
|
131
|
+
path_params = getattr(endpoint, "path_params", []) or []
|
|
132
|
+
path_params_schema = getattr(endpoint, "path_params_schema", {}) or {}
|
|
133
|
+
query_params = getattr(endpoint, "query_params", []) or []
|
|
134
|
+
query_params_schema = getattr(endpoint, "query_params_schema", {}) or {}
|
|
135
|
+
body_fields = getattr(endpoint, "body_fields", []) or []
|
|
136
|
+
request_schema = getattr(endpoint, "request_schema", None)
|
|
137
|
+
|
|
138
|
+
# Path params (always required)
|
|
139
|
+
for param_name in path_params:
|
|
140
|
+
schema = path_params_schema.get(param_name, {})
|
|
141
|
+
action_params.append(
|
|
142
|
+
{
|
|
143
|
+
"name": param_name,
|
|
144
|
+
"in": "path",
|
|
145
|
+
"required": True,
|
|
146
|
+
"type": schema.get("type", "string"),
|
|
147
|
+
"description": schema.get("description", ""),
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Query params
|
|
152
|
+
for param_name in query_params:
|
|
153
|
+
schema = query_params_schema.get(param_name, {})
|
|
154
|
+
action_params.append(
|
|
155
|
+
{
|
|
156
|
+
"name": param_name,
|
|
157
|
+
"in": "query",
|
|
158
|
+
"required": schema.get("required", False),
|
|
159
|
+
"type": schema.get("type", "string"),
|
|
160
|
+
"description": schema.get("description", ""),
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Body fields
|
|
165
|
+
if request_schema:
|
|
166
|
+
required_fields = request_schema.get("required", [])
|
|
167
|
+
properties = request_schema.get("properties", {})
|
|
168
|
+
for param_name in body_fields:
|
|
169
|
+
prop = properties.get(param_name, {})
|
|
170
|
+
action_params.append(
|
|
171
|
+
{
|
|
172
|
+
"name": param_name,
|
|
173
|
+
"in": "body",
|
|
174
|
+
"required": param_name in required_fields,
|
|
175
|
+
"type": prop.get("type", "string"),
|
|
176
|
+
"description": prop.get("description", ""),
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if action_params:
|
|
181
|
+
# Action is an enum, use .value to get string
|
|
182
|
+
action_key = action.value if hasattr(action, "value") else str(action)
|
|
183
|
+
parameters[action_key] = action_params
|
|
184
|
+
|
|
185
|
+
actions = getattr(entity_def, "actions", []) or []
|
|
186
|
+
entities.append(
|
|
187
|
+
{
|
|
188
|
+
"entity_name": entity_def.name,
|
|
189
|
+
"description": description,
|
|
190
|
+
"available_actions": [a.value if hasattr(a, "value") else str(a) for a in actions],
|
|
191
|
+
"parameters": parameters,
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return entities
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def generate_tool_description(model: ConnectorModelProtocol) -> str:
|
|
199
|
+
"""Generate AI tool description from connector metadata.
|
|
200
|
+
|
|
201
|
+
Produces a detailed description that includes:
|
|
202
|
+
- Per-entity/action parameter signatures with required (*) and optional (?) markers
|
|
203
|
+
- Response structure documentation with pagination hints
|
|
204
|
+
- Example questions if available in the OpenAPI spec
|
|
205
|
+
|
|
206
|
+
This is used by the Connector.describe class method decorator to populate
|
|
207
|
+
function docstrings for AI framework integration.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
model: Object conforming to ConnectorModelProtocol (e.g., ConnectorModel)
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Formatted description string suitable for AI tool documentation
|
|
214
|
+
"""
|
|
215
|
+
lines = []
|
|
216
|
+
|
|
217
|
+
# Entity/action parameter details (including pagination params like limit, starting_after)
|
|
218
|
+
lines.append("ENTITIES AND PARAMETERS:")
|
|
219
|
+
for entity in model.entities:
|
|
220
|
+
lines.append(f" {entity.name}:")
|
|
221
|
+
actions = getattr(entity, "actions", []) or []
|
|
222
|
+
endpoints = getattr(entity, "endpoints", {}) or {}
|
|
223
|
+
for action in actions:
|
|
224
|
+
action_str = action.value if hasattr(action, "value") else str(action)
|
|
225
|
+
endpoint = endpoints.get(action)
|
|
226
|
+
if endpoint:
|
|
227
|
+
param_sig = format_param_signature(endpoint)
|
|
228
|
+
lines.append(f" - {action_str}{param_sig}")
|
|
229
|
+
else:
|
|
230
|
+
lines.append(f" - {action_str}()")
|
|
231
|
+
|
|
232
|
+
# Response structure (brief, includes pagination hint)
|
|
233
|
+
lines.append("")
|
|
234
|
+
lines.append("RESPONSE STRUCTURE:")
|
|
235
|
+
lines.append(" - list/api_search: {data: [...], meta: {has_more: bool}}")
|
|
236
|
+
lines.append(" - get: Returns entity directly (no envelope)")
|
|
237
|
+
lines.append(" To paginate: pass starting_after=<last_id> while has_more is true")
|
|
238
|
+
|
|
239
|
+
# Add example questions if available in openapi_spec
|
|
240
|
+
openapi_spec = getattr(model, "openapi_spec", None)
|
|
241
|
+
if openapi_spec:
|
|
242
|
+
info = getattr(openapi_spec, "info", None)
|
|
243
|
+
if info:
|
|
244
|
+
example_questions = getattr(info, "x_airbyte_example_questions", None)
|
|
245
|
+
if example_questions:
|
|
246
|
+
supported = getattr(example_questions, "supported", None)
|
|
247
|
+
if supported:
|
|
248
|
+
lines.append("")
|
|
249
|
+
lines.append("EXAMPLE QUESTIONS:")
|
|
250
|
+
for q in supported[:MAX_EXAMPLE_QUESTIONS]:
|
|
251
|
+
lines.append(f" - {q}")
|
|
252
|
+
|
|
253
|
+
# Generic parameter description for function signature
|
|
254
|
+
lines.append("")
|
|
255
|
+
lines.append("FUNCTION PARAMETERS:")
|
|
256
|
+
lines.append(" - entity: Entity name (string)")
|
|
257
|
+
lines.append(" - action: Operation to perform (string)")
|
|
258
|
+
lines.append(" - params: Operation parameters (dict) - see entity details above")
|
|
259
|
+
lines.append("")
|
|
260
|
+
lines.append("Parameter markers: * = required, ? = optional")
|
|
261
|
+
|
|
262
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Request/response logging implementation."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Set
|
|
9
|
+
|
|
10
|
+
from .types import LogSession, RequestLog
|
|
11
|
+
|
|
12
|
+
# Headers to redact for security
|
|
13
|
+
SENSITIVE_HEADERS: Set[str] = {
|
|
14
|
+
"authorization",
|
|
15
|
+
"bearer",
|
|
16
|
+
"api-key",
|
|
17
|
+
"x-api-key",
|
|
18
|
+
"token",
|
|
19
|
+
"secret",
|
|
20
|
+
"password",
|
|
21
|
+
"credential",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RequestLogger:
|
|
26
|
+
"""Captures HTTP request/response interactions to a JSON file.
|
|
27
|
+
|
|
28
|
+
Implements bounded logging with automatic rotation and flush-before-discard
|
|
29
|
+
to prevent unbounded memory growth in long-running processes.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
log_file: str | None = None,
|
|
35
|
+
connector_name: str | None = None,
|
|
36
|
+
max_logs: int | None = 10000,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Initialize the request logger.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
log_file: Path to write logs. If None, generates timestamped filename.
|
|
43
|
+
connector_name: Name of the connector being logged.
|
|
44
|
+
max_logs: Maximum number of logs to keep in memory before rotation.
|
|
45
|
+
Set to None for unlimited (not recommended for production).
|
|
46
|
+
Defaults to 10000.
|
|
47
|
+
"""
|
|
48
|
+
if log_file is None:
|
|
49
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
50
|
+
log_file = f".logs/session_{timestamp}.json"
|
|
51
|
+
|
|
52
|
+
self.log_file = Path(log_file)
|
|
53
|
+
self.log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
self.session = LogSession(
|
|
56
|
+
session_id=str(uuid.uuid4()),
|
|
57
|
+
connector_name=connector_name,
|
|
58
|
+
max_logs=max_logs,
|
|
59
|
+
)
|
|
60
|
+
self._active_requests: Dict[str, Dict[str, Any]] = {}
|
|
61
|
+
# Store rotated logs that have been flushed from active buffer
|
|
62
|
+
self._rotated_logs: list[RequestLog] = []
|
|
63
|
+
|
|
64
|
+
def _redact_headers(self, headers: Dict[str, str]) -> Dict[str, str]:
|
|
65
|
+
"""Redact sensitive headers."""
|
|
66
|
+
redacted = {}
|
|
67
|
+
for key, value in headers.items():
|
|
68
|
+
if any(sensitive in key.lower() for sensitive in SENSITIVE_HEADERS):
|
|
69
|
+
redacted[key] = "[REDACTED]"
|
|
70
|
+
else:
|
|
71
|
+
redacted[key] = value
|
|
72
|
+
return redacted
|
|
73
|
+
|
|
74
|
+
def _rotate_logs_if_needed(self) -> None:
|
|
75
|
+
"""Rotate logs if max_logs limit is reached.
|
|
76
|
+
|
|
77
|
+
Moves oldest logs to _rotated_logs before removing them from active buffer.
|
|
78
|
+
This ensures logs are preserved for final save() without memory growth.
|
|
79
|
+
"""
|
|
80
|
+
max_logs = self.session.max_logs
|
|
81
|
+
if max_logs is None:
|
|
82
|
+
# Unlimited logging, no rotation needed
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
current_count = len(self.session.logs)
|
|
86
|
+
if current_count >= max_logs:
|
|
87
|
+
# Calculate how many logs to rotate (keep buffer at ~90% to avoid thrashing)
|
|
88
|
+
num_to_rotate = max(1, current_count - int(max_logs * 0.9))
|
|
89
|
+
|
|
90
|
+
# Move oldest logs to rotated buffer
|
|
91
|
+
rotated = self.session.logs[:num_to_rotate]
|
|
92
|
+
self._rotated_logs.extend(rotated)
|
|
93
|
+
|
|
94
|
+
# Remove rotated logs from active buffer
|
|
95
|
+
self.session.logs = self.session.logs[num_to_rotate:]
|
|
96
|
+
|
|
97
|
+
def log_request(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
url: str,
|
|
101
|
+
path: str,
|
|
102
|
+
headers: Dict[str, str] | None = None,
|
|
103
|
+
params: Dict[str, Any] | None = None,
|
|
104
|
+
body: Any | None = None,
|
|
105
|
+
) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Log the start of an HTTP request.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
method: HTTP method (GET, POST, etc.)
|
|
111
|
+
url: Full URL
|
|
112
|
+
path: Request path
|
|
113
|
+
headers: Request headers
|
|
114
|
+
params: Query parameters
|
|
115
|
+
body: Request body
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Request ID for correlating with response
|
|
119
|
+
"""
|
|
120
|
+
request_id = str(uuid.uuid4())
|
|
121
|
+
self._active_requests[request_id] = {
|
|
122
|
+
"start_time": time.time(),
|
|
123
|
+
"method": method,
|
|
124
|
+
"url": url,
|
|
125
|
+
"path": path,
|
|
126
|
+
"headers": self._redact_headers(headers or {}),
|
|
127
|
+
"params": params,
|
|
128
|
+
"body": body,
|
|
129
|
+
}
|
|
130
|
+
return request_id
|
|
131
|
+
|
|
132
|
+
def log_response(
|
|
133
|
+
self,
|
|
134
|
+
request_id: str,
|
|
135
|
+
status_code: int,
|
|
136
|
+
response_body: Any | None = None,
|
|
137
|
+
response_headers: Dict[str, str] | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Log a successful HTTP response.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
request_id: ID returned from log_request
|
|
144
|
+
status_code: HTTP status code
|
|
145
|
+
response_body: Response body
|
|
146
|
+
response_headers: Response headers
|
|
147
|
+
"""
|
|
148
|
+
if request_id not in self._active_requests:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
request_data = self._active_requests.pop(request_id)
|
|
152
|
+
timing_ms = (time.time() - request_data["start_time"]) * 1000
|
|
153
|
+
|
|
154
|
+
# Convert bytes to base64 for JSON serialization
|
|
155
|
+
serializable_body = response_body
|
|
156
|
+
if isinstance(response_body, bytes):
|
|
157
|
+
serializable_body = {
|
|
158
|
+
"_binary": True,
|
|
159
|
+
"_base64": base64.b64encode(response_body).decode("utf-8"),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
log_entry = RequestLog(
|
|
163
|
+
method=request_data["method"],
|
|
164
|
+
url=request_data["url"],
|
|
165
|
+
path=request_data["path"],
|
|
166
|
+
headers=request_data["headers"],
|
|
167
|
+
params=request_data["params"],
|
|
168
|
+
body=request_data["body"],
|
|
169
|
+
response_status=status_code,
|
|
170
|
+
response_body=serializable_body,
|
|
171
|
+
response_headers=response_headers or {},
|
|
172
|
+
timing_ms=timing_ms,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
self.session.logs.append(log_entry)
|
|
176
|
+
self._rotate_logs_if_needed()
|
|
177
|
+
|
|
178
|
+
def log_error(
|
|
179
|
+
self,
|
|
180
|
+
request_id: str,
|
|
181
|
+
error: str,
|
|
182
|
+
status_code: int | None = None,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Log an HTTP request error.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
request_id: ID returned from log_request
|
|
189
|
+
error: Error message
|
|
190
|
+
status_code: HTTP status code if available
|
|
191
|
+
"""
|
|
192
|
+
if request_id not in self._active_requests:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
request_data = self._active_requests.pop(request_id)
|
|
196
|
+
timing_ms = (time.time() - request_data["start_time"]) * 1000
|
|
197
|
+
|
|
198
|
+
log_entry = RequestLog(
|
|
199
|
+
method=request_data["method"],
|
|
200
|
+
url=request_data["url"],
|
|
201
|
+
path=request_data["path"],
|
|
202
|
+
headers=request_data["headers"],
|
|
203
|
+
params=request_data["params"],
|
|
204
|
+
body=request_data["body"],
|
|
205
|
+
response_status=status_code,
|
|
206
|
+
timing_ms=timing_ms,
|
|
207
|
+
error=error,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
self.session.logs.append(log_entry)
|
|
211
|
+
self._rotate_logs_if_needed()
|
|
212
|
+
|
|
213
|
+
def log_chunk_fetch(self, chunk: bytes) -> None:
|
|
214
|
+
"""Log a chunk from streaming response.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
chunk: Binary chunk data from streaming response
|
|
218
|
+
"""
|
|
219
|
+
self.session.chunk_logs.append(chunk)
|
|
220
|
+
|
|
221
|
+
def save(self) -> None:
|
|
222
|
+
"""Write the current session to the log file.
|
|
223
|
+
|
|
224
|
+
Includes both rotated logs and current active logs to ensure
|
|
225
|
+
no data loss during bounded logging.
|
|
226
|
+
"""
|
|
227
|
+
# Combine rotated logs with current logs for complete session
|
|
228
|
+
all_logs = self._rotated_logs + self.session.logs
|
|
229
|
+
|
|
230
|
+
# Create a temporary session with all logs for serialization
|
|
231
|
+
session_data = self.session.model_dump(mode="json")
|
|
232
|
+
session_data["logs"] = [log.model_dump(mode="json") for log in all_logs]
|
|
233
|
+
|
|
234
|
+
with open(self.log_file, "w") as f:
|
|
235
|
+
json.dump(session_data, f, indent=2, default=str)
|
|
236
|
+
|
|
237
|
+
def close(self) -> None:
|
|
238
|
+
"""Finalize and save the logging session."""
|
|
239
|
+
self.save()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class NullLogger:
|
|
243
|
+
"""No-op logger for when logging is disabled."""
|
|
244
|
+
|
|
245
|
+
def log_request(self, *args, **kwargs) -> str:
|
|
246
|
+
"""No-op log_request."""
|
|
247
|
+
return ""
|
|
248
|
+
|
|
249
|
+
def log_response(
|
|
250
|
+
self,
|
|
251
|
+
request_id: str,
|
|
252
|
+
status_code: int,
|
|
253
|
+
response_body: Any | None = None,
|
|
254
|
+
response_headers: Dict[str, str] | None = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""No-op log_response."""
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
def log_error(self, *args, **kwargs) -> None:
|
|
260
|
+
"""No-op log_error."""
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
def log_chunk_fetch(self, chunk: bytes) -> None:
|
|
264
|
+
"""No-op chunk logging for production."""
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
def save(self) -> None:
|
|
268
|
+
"""No-op save."""
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
def close(self) -> None:
|
|
272
|
+
"""No-op close."""
|
|
273
|
+
pass
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Type definitions for request/response logging."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _utc_now() -> datetime:
|
|
11
|
+
"""Get current UTC datetime (timezone-aware)."""
|
|
12
|
+
return datetime.now(UTC)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _encode_bytes(v: bytes) -> dict:
|
|
16
|
+
"""Encode bytes as base64 for JSON serialization."""
|
|
17
|
+
return {"_binary": True, "_base64": base64.b64encode(v).decode("utf-8")}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RequestLog(BaseModel):
|
|
21
|
+
"""Captures a single HTTP request/response interaction."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict()
|
|
24
|
+
|
|
25
|
+
timestamp: datetime = Field(default_factory=_utc_now)
|
|
26
|
+
method: str
|
|
27
|
+
url: str
|
|
28
|
+
path: str
|
|
29
|
+
headers: Dict[str, str] = Field(default_factory=dict)
|
|
30
|
+
params: Dict[str, Any] | None = None
|
|
31
|
+
body: Any | None = None
|
|
32
|
+
response_status: int | None = None
|
|
33
|
+
response_body: Any | None = None
|
|
34
|
+
response_headers: Dict[str, str] = Field(default_factory=dict)
|
|
35
|
+
timing_ms: float | None = None
|
|
36
|
+
error: str | None = None
|
|
37
|
+
|
|
38
|
+
@field_serializer("timestamp")
|
|
39
|
+
def serialize_datetime(self, value: datetime) -> str:
|
|
40
|
+
return value.isoformat()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LogSession(BaseModel):
|
|
44
|
+
"""Collection of request logs with session metadata.
|
|
45
|
+
|
|
46
|
+
When max_logs is set, the session will maintain a bounded buffer of recent logs.
|
|
47
|
+
Older logs should be flushed to disk before being discarded (handled by RequestLogger).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict()
|
|
51
|
+
|
|
52
|
+
session_id: str
|
|
53
|
+
started_at: datetime = Field(default_factory=_utc_now)
|
|
54
|
+
connector_name: str | None = None
|
|
55
|
+
logs: List[RequestLog] = Field(default_factory=list)
|
|
56
|
+
max_logs: int | None = Field(
|
|
57
|
+
default=10000,
|
|
58
|
+
description="Maximum number of logs to keep in memory. "
|
|
59
|
+
"When limit is reached, oldest logs should be flushed before removal. "
|
|
60
|
+
"Set to None for unlimited (not recommended for production).",
|
|
61
|
+
)
|
|
62
|
+
chunk_logs: List[bytes] = Field(
|
|
63
|
+
default_factory=list,
|
|
64
|
+
description="Captured chunks from streaming responses. Each chunk is logged when log_chunk_fetch() is called.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@field_validator("chunk_logs", mode="before")
|
|
68
|
+
@classmethod
|
|
69
|
+
def decode_chunk_logs(cls, v: Any) -> List[bytes]:
|
|
70
|
+
"""Decode chunk_logs from JSON representation back to bytes."""
|
|
71
|
+
if v is None or v == []:
|
|
72
|
+
return []
|
|
73
|
+
if isinstance(v, list):
|
|
74
|
+
result = []
|
|
75
|
+
for item in v:
|
|
76
|
+
if isinstance(item, bytes):
|
|
77
|
+
result.append(item)
|
|
78
|
+
elif isinstance(item, dict) and item.get("_binary"):
|
|
79
|
+
# Decode from {"_binary": True, "_base64": "..."} format
|
|
80
|
+
result.append(base64.b64decode(item["_base64"]))
|
|
81
|
+
else:
|
|
82
|
+
result.append(item)
|
|
83
|
+
return result
|
|
84
|
+
return v
|
|
85
|
+
|
|
86
|
+
@field_serializer("started_at")
|
|
87
|
+
def serialize_datetime(self, value: datetime) -> str:
|
|
88
|
+
return value.isoformat()
|
|
89
|
+
|
|
90
|
+
@field_serializer("chunk_logs")
|
|
91
|
+
def serialize_chunk_logs(self, value: List[bytes]) -> List[dict]:
|
|
92
|
+
"""Serialize bytes chunks as base64 for JSON."""
|
|
93
|
+
return [_encode_bytes(chunk) for chunk in value]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Shared observability components for logging and telemetry."""
|
|
2
|
+
|
|
3
|
+
from .models import OperationMetadata
|
|
4
|
+
from .redactor import DataRedactor
|
|
5
|
+
from .session import ObservabilitySession
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DataRedactor",
|
|
9
|
+
"ObservabilitySession",
|
|
10
|
+
"OperationMetadata",
|
|
11
|
+
]
|