genkit-plugin-google-cloud 0.4.0__py3-none-any.whl → 0.5.0__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.
@@ -0,0 +1,246 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # SPDX-License-Identifier: Apache-2.0
16
+
17
+ """AI monitoring metrics for Genkit.
18
+
19
+ This module provides lazy-initialized OpenTelemetry metrics for AI operations.
20
+ Metrics are exported to Google Cloud Monitoring with the workload.googleapis.com
21
+ prefix by default.
22
+
23
+ Metrics Defined:
24
+ Input metrics:
25
+ - genkit/ai/generate/input/tokens
26
+ - genkit/ai/generate/input/characters
27
+ - genkit/ai/generate/input/images
28
+ - genkit/ai/generate/input/videos
29
+ - genkit/ai/generate/input/audio
30
+
31
+ Output metrics:
32
+ - genkit/ai/generate/output/tokens
33
+ - genkit/ai/generate/output/characters
34
+ - genkit/ai/generate/output/images
35
+ - genkit/ai/generate/output/videos
36
+ - genkit/ai/generate/output/audio
37
+
38
+ Thinking metrics:
39
+ - genkit/ai/generate/thinking/tokens
40
+
41
+ See Also:
42
+ - Cloud Monitoring Custom Metrics: https://cloud.google.com/monitoring/custom-metrics
43
+ - Workload Metrics: https://cloud.google.com/monitoring/api/metrics_other
44
+ """
45
+
46
+ import contextlib
47
+ import json
48
+ import re
49
+
50
+ import structlog
51
+ from opentelemetry import metrics
52
+ from opentelemetry.sdk.trace import ReadableSpan
53
+
54
+ logger = structlog.get_logger(__name__)
55
+
56
+ meter = metrics.get_meter('genkit')
57
+
58
+
59
+ def _metric(name: str, desc: str, unit: str = '1') -> tuple[str, str, str]:
60
+ """Create metric name with genkit/ai/ prefix.
61
+
62
+ Args:
63
+ name: Metric name
64
+ desc: Metric description
65
+ unit: Metric unit (default: '1')
66
+
67
+ Returns:
68
+ Tuple of (prefixed_name, description, unit)
69
+ """
70
+ return f'genkit/ai/{name}', desc, unit
71
+
72
+
73
+ # Metric caches for lazy initialization
74
+ _counter_cache: dict[str, metrics.Counter] = {}
75
+ _histogram_cache: dict[str, metrics.Histogram] = {}
76
+
77
+
78
+ def _get_counter(name: str, desc: str, unit: str = '1') -> metrics.Counter:
79
+ """Get or create counter metric with lazy initialization.
80
+
81
+ Args:
82
+ name: Metric name
83
+ desc: Metric description
84
+ unit: Metric unit (default: '1')
85
+
86
+ Returns:
87
+ OpenTelemetry Counter metric
88
+ """
89
+ if name not in _counter_cache:
90
+ _counter_cache[name] = meter.create_counter(name, description=desc, unit=unit)
91
+ return _counter_cache[name]
92
+
93
+
94
+ def _get_histogram(name: str, desc: str, unit: str = '1') -> metrics.Histogram:
95
+ """Get or create histogram metric with lazy initialization.
96
+
97
+ Args:
98
+ name: Metric name
99
+ desc: Metric description
100
+ unit: Metric unit (default: '1')
101
+
102
+ Returns:
103
+ OpenTelemetry Histogram metric
104
+ """
105
+ if name not in _histogram_cache:
106
+ _histogram_cache[name] = meter.create_histogram(name, description=desc, unit=unit)
107
+ return _histogram_cache[name]
108
+
109
+
110
+ # Metric definitions
111
+ def _requests() -> metrics.Counter:
112
+ return _get_counter(*_metric('generate/requests', 'Generate requests'))
113
+
114
+
115
+ def _failures() -> metrics.Counter:
116
+ return _get_counter(*_metric('generate/failures', 'Generate failures'))
117
+
118
+
119
+ def _latency() -> metrics.Histogram:
120
+ return _get_histogram(*_metric('generate/latency', 'Generate latency', 'ms'))
121
+
122
+
123
+ def _input_tokens() -> metrics.Counter:
124
+ return _get_counter(*_metric('generate/input/tokens', 'Input tokens'))
125
+
126
+
127
+ def _output_tokens() -> metrics.Counter:
128
+ return _get_counter(*_metric('generate/output/tokens', 'Output tokens'))
129
+
130
+
131
+ def _input_characters() -> metrics.Counter:
132
+ return _get_counter(*_metric('generate/input/characters', 'Input characters'))
133
+
134
+
135
+ def _output_characters() -> metrics.Counter:
136
+ return _get_counter(*_metric('generate/output/characters', 'Output characters'))
137
+
138
+
139
+ def _input_images() -> metrics.Counter:
140
+ return _get_counter(*_metric('generate/input/images', 'Input images'))
141
+
142
+
143
+ def _output_images() -> metrics.Counter:
144
+ return _get_counter(*_metric('generate/output/images', 'Output images'))
145
+
146
+
147
+ def _input_videos() -> metrics.Counter:
148
+ return _get_counter(*_metric('generate/input/videos', 'Input videos'))
149
+
150
+
151
+ def _output_videos() -> metrics.Counter:
152
+ return _get_counter(*_metric('generate/output/videos', 'Output videos'))
153
+
154
+
155
+ def _input_audio() -> metrics.Counter:
156
+ return _get_counter(*_metric('generate/input/audio', 'Input audio'))
157
+
158
+
159
+ def _output_audio() -> metrics.Counter:
160
+ return _get_counter(*_metric('generate/output/audio', 'Output audio'))
161
+
162
+
163
+ def record_generate_metrics(span: ReadableSpan) -> None:
164
+ """Record AI monitoring metrics from a model action span.
165
+
166
+ Args:
167
+ span: OpenTelemetry span containing model execution data
168
+ """
169
+ attrs = span.attributes
170
+ if not attrs:
171
+ return
172
+
173
+ # Check if this is a model action
174
+ if attrs.get('genkit:type') != 'action' or attrs.get('genkit:metadata:subtype') != 'model':
175
+ return
176
+
177
+ # Extract dimensions
178
+ model = str(attrs.get('genkit:name', '<unknown>'))[:1000]
179
+ path = str(attrs.get('genkit:path', ''))[:1000]
180
+ source = _extract_feature_name(path)
181
+ is_error = not span.status.is_ok
182
+ error = 'error' if is_error else 'none'
183
+
184
+ dimensions = {'model': model, 'source': source, 'error': error}
185
+
186
+ try:
187
+ _requests().add(1, dimensions)
188
+ if is_error:
189
+ _failures().add(1, dimensions)
190
+
191
+ # Latency
192
+ latency_ms = None
193
+ if span.end_time and span.start_time:
194
+ latency_ms = (span.end_time - span.start_time) / 1_000_000
195
+ _latency().record(latency_ms, dimensions)
196
+
197
+ usage = {}
198
+ output_json = attrs.get('genkit:output')
199
+ if output_json and isinstance(output_json, str):
200
+ try:
201
+ output_data = json.loads(output_json)
202
+ usage = output_data.get('usage', {})
203
+ except (json.JSONDecodeError, AttributeError):
204
+ pass
205
+
206
+ usage_metrics = {
207
+ 'inputTokens': _input_tokens,
208
+ 'outputTokens': _output_tokens,
209
+ 'inputCharacters': _input_characters,
210
+ 'outputCharacters': _output_characters,
211
+ 'inputImages': _input_images,
212
+ 'outputImages': _output_images,
213
+ 'inputVideos': _input_videos,
214
+ 'outputVideos': _output_videos,
215
+ 'inputAudio': _input_audio,
216
+ 'outputAudio': _output_audio,
217
+ }
218
+
219
+ for key, metric_fn in usage_metrics.items():
220
+ value = usage.get(key)
221
+ if value is not None:
222
+ with contextlib.suppress(ValueError, TypeError):
223
+ metric_fn().add(int(value), dimensions)
224
+
225
+ except Exception as e:
226
+ logger.warning('Error recording metrics', error=str(e))
227
+
228
+
229
+ def _extract_feature_name(path: str) -> str:
230
+ """Extract feature name from Genkit action path.
231
+
232
+ Args:
233
+ path: Genkit action path in format '/{name,t:type}' or '/{outer,t:flow}/{inner,t:flow}'
234
+
235
+ Returns:
236
+ Extracted feature name or '<unknown>' if path cannot be parsed
237
+ """
238
+ if not path:
239
+ return '<unknown>'
240
+
241
+ parts = path.split('/')
242
+ if len(parts) < 2:
243
+ return '<unknown>'
244
+
245
+ match = re.match(r'\{([^,}]+)', parts[1])
246
+ return match.group(1) if match else '<unknown>'
@@ -0,0 +1,157 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ #
15
+ # SPDX-License-Identifier: Apache-2.0
16
+
17
+ """Path telemetry for GCP.
18
+
19
+ This module tracks path-level failure metrics and logs errors,
20
+ matching the JavaScript implementation.
21
+
22
+ Metrics Recorded:
23
+ - genkit/feature/path/requests: Counter for unique flow paths
24
+ - genkit/feature/path/latency: Histogram for path latency (ms)
25
+
26
+ Cross-Language Parity:
27
+ - JavaScript: js/plugins/google-cloud/src/telemetry/paths.ts
28
+ - Go: go/plugins/googlecloud/paths.go
29
+
30
+ See Also:
31
+ - Cloud Monitoring Custom Metrics: https://cloud.google.com/monitoring/custom-metrics
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import structlog
37
+ from opentelemetry import metrics
38
+ from opentelemetry.sdk.trace import ReadableSpan
39
+
40
+ from genkit.core import GENKIT_VERSION
41
+
42
+ from .utils import (
43
+ create_common_log_attributes,
44
+ extract_error_message,
45
+ extract_error_name,
46
+ extract_error_stack,
47
+ extract_outer_feature_name_from_path,
48
+ to_display_path,
49
+ truncate_path,
50
+ )
51
+
52
+ logger = structlog.get_logger(__name__)
53
+
54
+ # Lazy-initialized metrics
55
+ _path_counter: metrics.Counter | None = None
56
+ _path_latency: metrics.Histogram | None = None
57
+
58
+
59
+ def _get_path_counter() -> metrics.Counter:
60
+ """Get or create the path requests counter."""
61
+ global _path_counter
62
+ if _path_counter is None:
63
+ meter = metrics.get_meter('genkit')
64
+ _path_counter = meter.create_counter(
65
+ 'genkit/feature/path/requests',
66
+ description='Tracks unique flow paths per flow.',
67
+ unit='1',
68
+ )
69
+ return _path_counter
70
+
71
+
72
+ def _get_path_latency() -> metrics.Histogram:
73
+ """Get or create the path latency histogram."""
74
+ global _path_latency
75
+ if _path_latency is None:
76
+ meter = metrics.get_meter('genkit')
77
+ _path_latency = meter.create_histogram(
78
+ 'genkit/feature/path/latency',
79
+ description='Latencies per flow path.',
80
+ unit='ms',
81
+ )
82
+ return _path_latency
83
+
84
+
85
+ class PathsTelemetry:
86
+ """Telemetry handler for Genkit paths (error tracking)."""
87
+
88
+ def tick(
89
+ self,
90
+ span: ReadableSpan,
91
+ log_input_and_output: bool,
92
+ project_id: str | None = None,
93
+ ) -> None:
94
+ """Record telemetry for a path span.
95
+
96
+ Only ticks metrics for failing, leaf spans (isFailureSource).
97
+
98
+ Args:
99
+ span: The span to record telemetry for.
100
+ log_input_and_output: Whether to log input/output (unused here).
101
+ project_id: Optional GCP project ID.
102
+ """
103
+ attrs = span.attributes or {}
104
+
105
+ path = str(attrs.get('genkit:path', ''))
106
+ is_failure_source = bool(attrs.get('genkit:isFailureSource'))
107
+ state = str(attrs.get('genkit:state', ''))
108
+
109
+ # Only tick metrics for failing, leaf spans
110
+ if not path or not is_failure_source or state != 'error':
111
+ return
112
+
113
+ session_id = str(attrs.get('genkit:sessionId', '')) or None
114
+ thread_name = str(attrs.get('genkit:threadName', '')) or None
115
+
116
+ events = list(span.events)
117
+ error_name = extract_error_name(events) or '<unknown>'
118
+ error_message = extract_error_message(events) or '<unknown>'
119
+ error_stack = extract_error_stack(events) or ''
120
+
121
+ # Calculate latency
122
+ latency_ms = 0.0
123
+ if span.end_time and span.start_time:
124
+ latency_ms = (span.end_time - span.start_time) / 1_000_000
125
+
126
+ path_dimensions = {
127
+ 'featureName': extract_outer_feature_name_from_path(path)[:256],
128
+ 'status': 'failure',
129
+ 'error': error_name[:256],
130
+ 'path': path[:256],
131
+ 'source': 'py',
132
+ 'sourceVersion': GENKIT_VERSION,
133
+ }
134
+ _get_path_counter().add(1, path_dimensions)
135
+ _get_path_latency().record(latency_ms, path_dimensions)
136
+
137
+ display_path = truncate_path(to_display_path(path))
138
+ log_attrs = {
139
+ **create_common_log_attributes(span, project_id),
140
+ 'path': display_path,
141
+ 'qualifiedPath': path,
142
+ 'name': error_name,
143
+ 'message': error_message,
144
+ 'stack': error_stack,
145
+ 'source': 'py',
146
+ 'sourceVersion': GENKIT_VERSION,
147
+ }
148
+ if session_id:
149
+ log_attrs['sessionId'] = session_id
150
+ if thread_name:
151
+ log_attrs['threadName'] = thread_name
152
+
153
+ logger.error(f'Error[{display_path}, {error_name}]', **log_attrs)
154
+
155
+
156
+ # Singleton instance
157
+ paths_telemetry = PathsTelemetry()