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+ from __future__ import annotations
16+
17+ import time
18+
19+ from opentelemetry .sdk .metrics import MeterProvider
20+ from opentelemetry .sdk .metrics import view
21+ from opentelemetry .sdk .metrics .export import (
22+ HistogramDataPoint ,
23+ MetricExporter ,
24+ MetricExportResult ,
25+ MetricsData ,
26+ NumberDataPoint ,
27+ PeriodicExportingMetricReader ,
28+ )
29+ from google .protobuf .timestamp_pb2 import Timestamp
30+ from google .api .distribution_pb2 import Distribution
31+ from google .api .metric_pb2 import Metric as GMetric
32+ from google .api .monitored_resource_pb2 import MonitoredResource
33+ from google .api .metric_pb2 import MetricDescriptor
34+ from google .api_core import gapic_v1
35+ from google .cloud .monitoring_v3 import (
36+ CreateTimeSeriesRequest ,
37+ MetricServiceClient ,
38+ Point ,
39+ TimeInterval ,
40+ TimeSeries ,
41+ TypedValue ,
42+ )
43+
44+ from google .cloud .bigtable .data ._metrics .handlers .opentelemetry import (
45+ OpenTelemetryMetricsHandler ,
46+ )
47+ from google .cloud .bigtable .data ._metrics .handlers .opentelemetry import (
48+ _OpenTelemetryInstruments ,
49+ )
50+
51+
52+ # create OpenTelemetry views for Bigtable metrics
53+ # avoid reformatting into individual lines
54+ # fmt: off
55+ MILLIS_AGGREGATION = view .ExplicitBucketHistogramAggregation (
56+ [
57+ 0 , 0.01 , 0.05 , 0.1 , 0.3 , 0.6 , 0.8 , 1 , 2 , 3 , 4 , 5 , 6 , 8 , 10 , 13 , 16 ,
58+ 20 , 25 , 30 , 40 , 50 , 65 , 80 , 100 , 130 , 160 , 200 , 250 , 300 , 400 ,
59+ 500 , 650 , 800 , 1000 , 2000 , 5000 , 10000 , 20000 , 50000 , 100000 ,
60+ ]
61+ )
62+ # fmt: on
63+ COUNT_AGGREGATION = view .SumAggregation ()
64+ INSTRUMENT_NAMES = (
65+ "operation_latencies" ,
66+ "first_response_latencies" ,
67+ "attempt_latencies" ,
68+ "retry_count" ,
69+ "server_latencies" ,
70+ "connectivity_error_count" ,
71+ "application_latencies" ,
72+ "throttling_latencies" ,
73+ )
74+ VIEW_LIST = [
75+ view .View (
76+ instrument_name = n ,
77+ name = n ,
78+ aggregation = MILLIS_AGGREGATION
79+ if n .endswith ("latencies" )
80+ else COUNT_AGGREGATION ,
81+ )
82+ for n in INSTRUMENT_NAMES
83+ ]
84+
85+
86+ class GoogleCloudMetricsHandler (OpenTelemetryMetricsHandler ):
87+ """
88+ Maintains an internal set of OpenTelemetry metrics for the Bigtable client library,
89+ and periodically exports them to Google Cloud Monitoring.
90+
91+ The OpenTelemetry metrics that are tracked are as follows:
92+ - operation_latencies: latency of each client method call, over all of it's attempts.
93+ - first_response_latencies: latency of receiving the first row in a ReadRows operation.
94+ - attempt_latencies: latency of each client attempt RPC.
95+ - retry_count: Number of additional RPCs sent after the initial attempt.
96+ - server_latencies: latency recorded on the server side for each attempt.
97+ - connectivity_error_count: number of attempts that failed to reach Google's network.
98+ - application_latencies: the time spent waiting for the application to process the next response.
99+ - throttling_latencies: latency introduced by waiting when there are too many outstanding requests in a bulk operation.
100+
101+ Args:
102+ - project_id: The Google Cloud project ID for the associated Bigtable Table
103+ - export_interval: The interval (in seconds) at which to export metrics to Cloud Monitoring.
104+ """
105+
106+ def __init__ (self , * args , project_id : str , export_interval = 60 , ** kwargs ):
107+ # internal exporter to write metrics to Cloud Monitoring
108+ exporter = _BigtableMetricsExporter (project_id = project_id )
109+ # periodically executes exporter
110+ gcp_reader = PeriodicExportingMetricReader (
111+ exporter , export_interval_millis = export_interval * 1000
112+ )
113+ # use private meter provider to store instruments and views
114+ meter_provider = MeterProvider (metric_readers = [gcp_reader ], views = VIEW_LIST )
115+ otel = _OpenTelemetryInstruments (meter_provider = meter_provider )
116+ super ().__init__ (* args , instruments = otel , project_id = project_id , ** kwargs )
117+
118+
119+ class _BigtableMetricsExporter (MetricExporter ):
120+ """
121+ OpenTelemetry Exporter implementation for sending metrics to Google Cloud Monitoring.
122+
123+ We must use a custom exporter because the public one doesn't support writing to internal
124+ metrics like `bigtable.googleapis.com/internal/client/`
125+
126+ Each GoogleCloudMetricsHandler will maintain its own exporter instance associated with the
127+ project_id it is configured with.
128+
129+ Args:
130+ - project_id: GCP project id to associate metrics with
131+ """
132+
133+ def __init__ (self , project_id : str ):
134+ super ().__init__ ()
135+ self .client = MetricServiceClient ()
136+ self .prefix = "bigtable.googleapis.com/internal/client"
137+ self .project_name = self .client .common_project_path (project_id )
138+
139+ def export (
140+ self , metrics_data : MetricsData , timeout_millis : float = 10_000 , ** kwargs
141+ ) -> MetricExportResult :
142+ """
143+ Write a set of metrics to Cloud Monitoring.
144+ This method is called by the OpenTelemetry SDK
145+ """
146+ deadline = time .time () + (timeout_millis / 1000 )
147+ metric_kind = MetricDescriptor .MetricKind .CUMULATIVE
148+ all_series : list [TimeSeries ] = []
149+ # process each metric from OTel format into Cloud Monitoring format
150+ for resource_metric in metrics_data .resource_metrics :
151+ for scope_metric in resource_metric .scope_metrics :
152+ for metric in scope_metric .metrics :
153+ for data_point in [
154+ pt for pt in metric .data .data_points if pt .attributes
155+ ]:
156+ if data_point .attributes :
157+ project_id = data_point .attributes .get ("resource_project" )
158+ if not isinstance (project_id , str ):
159+ # we expect string for project_id field
160+ continue
161+ monitored_resource = MonitoredResource (
162+ type = "bigtable_client_raw" ,
163+ labels = {
164+ "project_id" : project_id ,
165+ "instance" : data_point .attributes [
166+ "resource_instance"
167+ ],
168+ "cluster" : data_point .attributes [
169+ "resource_cluster"
170+ ],
171+ "table" : data_point .attributes ["resource_table" ],
172+ "zone" : data_point .attributes ["resource_zone" ],
173+ },
174+ )
175+ point = self ._to_point (data_point )
176+ series = TimeSeries (
177+ resource = monitored_resource ,
178+ metric_kind = metric_kind ,
179+ points = [point ],
180+ metric = GMetric (
181+ type = f"{ self .prefix } /{ metric .name } " ,
182+ labels = {
183+ k : v
184+ for k , v in data_point .attributes .items ()
185+ if not k .startswith ("resource_" )
186+ },
187+ ),
188+ unit = metric .unit ,
189+ )
190+ all_series .append (series )
191+ # send all metrics to Cloud Monitoring
192+ try :
193+ self ._batch_write (all_series , deadline )
194+ return MetricExportResult .SUCCESS
195+ except Exception :
196+ return MetricExportResult .FAILURE
197+
198+ def _batch_write (
199+ self , series : list [TimeSeries ], deadline = None , max_batch_size = 200
200+ ) -> None :
201+ """
202+ Adapted from CloudMonitoringMetricsExporter
203+ https://github.com/GoogleCloudPlatform/opentelemetry-operations-python/blob/3668dfe7ce3b80dd01f42af72428de957b58b316/opentelemetry-exporter-gcp-monitoring/src/opentelemetry/exporter/cloud_monitoring/__init__.py#L82
204+
205+ Args:
206+ - series: list of TimeSeries to write. Will be split into batches if necessary
207+ - deadline: designates the time.time() at which to stop writing. If None, uses API default
208+ - max_batch_size: maximum number of time series to write at once.
209+ Cloud Monitoring allows up to 200 per request
210+ """
211+ write_ind = 0
212+ while write_ind < len (series ):
213+ # find time left for next batch
214+ timeout = deadline - time .time () if deadline else gapic_v1 .method .DEFAULT
215+ # write next batch
216+ self .client .create_service_time_series (
217+ CreateTimeSeriesRequest (
218+ name = self .project_name ,
219+ time_series = series [write_ind : write_ind + max_batch_size ],
220+ ),
221+ timeout = timeout ,
222+ )
223+ write_ind += max_batch_size
224+
225+ @staticmethod
226+ def _to_point (data_point : NumberDataPoint | HistogramDataPoint ) -> Point :
227+ """
228+ Adapted from CloudMonitoringMetricsExporter
229+ https://github.com/GoogleCloudPlatform/opentelemetry-operations-python/blob/3668dfe7ce3b80dd01f42af72428de957b58b316/opentelemetry-exporter-gcp-monitoring/src/opentelemetry/exporter/cloud_monitoring/__init__.py#L82
230+ """
231+ if isinstance (data_point , HistogramDataPoint ):
232+ mean = data_point .sum / data_point .count if data_point .count else 0.0
233+ point_value = TypedValue (
234+ distribution_value = Distribution (
235+ count = data_point .count ,
236+ mean = mean ,
237+ bucket_counts = data_point .bucket_counts ,
238+ bucket_options = Distribution .BucketOptions (
239+ explicit_buckets = Distribution .BucketOptions .Explicit (
240+ bounds = data_point .explicit_bounds ,
241+ )
242+ ),
243+ )
244+ )
245+ else :
246+ if isinstance (data_point .value , int ):
247+ point_value = TypedValue (int64_value = data_point .value )
248+ else :
249+ point_value = TypedValue (double_value = data_point .value )
250+ start_time = Timestamp ()
251+ start_time .FromNanoseconds (data_point .start_time_unix_nano )
252+ end_time = Timestamp ()
253+ end_time .FromNanoseconds (data_point .time_unix_nano )
254+ interval = TimeInterval (start_time = start_time , end_time = end_time )
255+ return Point (interval = interval , value = point_value )
256+
257+ def shutdown (self , timeout_millis : float = 30_000 , ** kwargs ):
258+ """
259+ Adapted from CloudMonitoringMetricsExporter
260+ https://github.com/GoogleCloudPlatform/opentelemetry-operations-python/blob/3668dfe7ce3b80dd01f42af72428de957b58b316/opentelemetry-exporter-gcp-monitoring/src/opentelemetry/exporter/cloud_monitoring/__init__.py#L82
261+ """
262+ pass
263+
264+ def force_flush (self , timeout_millis : float = 10_000 ):
265+ """
266+ Adapted from CloudMonitoringMetricsExporter
267+ https://github.com/GoogleCloudPlatform/opentelemetry-operations-python/blob/3668dfe7ce3b80dd01f42af72428de957b58b316/opentelemetry-exporter-gcp-monitoring/src/opentelemetry/exporter/cloud_monitoring/__init__.py#L82
268+ """
269+ return True
0 commit comments