genai-otel-instrument 0.1.4.dev0__py3-none-any.whl → 0.1.9.dev0__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.
Potentially problematic release.
This version of genai-otel-instrument might be problematic. Click here for more details.
- genai_otel/__version__.py +2 -2
- genai_otel/auto_instrument.py +7 -3
- genai_otel/config.py +19 -1
- genai_otel/cost_calculator.py +72 -6
- genai_otel/cost_enriching_exporter.py +207 -0
- genai_otel/cost_enrichment_processor.py +174 -177
- genai_otel/gpu_metrics.py +50 -0
- genai_otel/instrumentors/base.py +228 -4
- genai_otel/instrumentors/cohere_instrumentor.py +140 -140
- genai_otel/instrumentors/huggingface_instrumentor.py +184 -7
- genai_otel/instrumentors/langchain_instrumentor.py +75 -75
- genai_otel/instrumentors/mistralai_instrumentor.py +17 -33
- genai_otel/llm_pricing.json +869 -869
- genai_otel/logging_config.py +45 -45
- genai_otel/py.typed +2 -2
- {genai_otel_instrument-0.1.4.dev0.dist-info → genai_otel_instrument-0.1.9.dev0.dist-info}/METADATA +256 -28
- {genai_otel_instrument-0.1.4.dev0.dist-info → genai_otel_instrument-0.1.9.dev0.dist-info}/RECORD +21 -20
- {genai_otel_instrument-0.1.4.dev0.dist-info → genai_otel_instrument-0.1.9.dev0.dist-info}/WHEEL +0 -0
- {genai_otel_instrument-0.1.4.dev0.dist-info → genai_otel_instrument-0.1.9.dev0.dist-info}/entry_points.txt +0 -0
- {genai_otel_instrument-0.1.4.dev0.dist-info → genai_otel_instrument-0.1.9.dev0.dist-info}/licenses/LICENSE +0 -0
- {genai_otel_instrument-0.1.4.dev0.dist-info → genai_otel_instrument-0.1.9.dev0.dist-info}/top_level.txt +0 -0
genai_otel/__version__.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.1.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
31
|
+
__version__ = version = '0.1.9.dev0'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 1, 9, 'dev0')
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
genai_otel/auto_instrument.py
CHANGED
|
@@ -19,6 +19,7 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExport
|
|
|
19
19
|
from .config import OTelConfig
|
|
20
20
|
from .cost_calculator import CostCalculator
|
|
21
21
|
from .cost_enrichment_processor import CostEnrichmentSpanProcessor
|
|
22
|
+
from .cost_enriching_exporter import CostEnrichingSpanExporter
|
|
22
23
|
from .gpu_metrics import GPUMetricsCollector
|
|
23
24
|
from .mcp_instrumentors import MCPInstrumentorManager
|
|
24
25
|
from .metrics import (
|
|
@@ -169,14 +170,17 @@ def setup_auto_instrumentation(config: OTelConfig):
|
|
|
169
170
|
|
|
170
171
|
set_global_textmap(TraceContextTextMapPropagator())
|
|
171
172
|
|
|
172
|
-
# Add cost enrichment processor for
|
|
173
|
-
#
|
|
173
|
+
# Add cost enrichment processor for custom instrumentors (OpenAI, Ollama, etc.)
|
|
174
|
+
# These instrumentors set cost attributes directly, so processor is mainly for logging
|
|
175
|
+
# Also attempts to enrich OpenInference spans (smolagents, litellm, mcp), though
|
|
176
|
+
# the processor can't modify ReadableSpan - the exporter below handles that
|
|
177
|
+
cost_calculator = None
|
|
174
178
|
if config.enable_cost_tracking:
|
|
175
179
|
try:
|
|
176
180
|
cost_calculator = CostCalculator()
|
|
177
181
|
cost_processor = CostEnrichmentSpanProcessor(cost_calculator)
|
|
178
182
|
tracer_provider.add_span_processor(cost_processor)
|
|
179
|
-
logger.info("Cost enrichment processor added
|
|
183
|
+
logger.info("Cost enrichment processor added")
|
|
180
184
|
except Exception as e:
|
|
181
185
|
logger.warning(f"Failed to add cost enrichment processor: {e}", exc_info=True)
|
|
182
186
|
|
genai_otel/config.py
CHANGED
|
@@ -11,7 +11,7 @@ import logging
|
|
|
11
11
|
import os
|
|
12
12
|
import sys
|
|
13
13
|
from dataclasses import dataclass, field
|
|
14
|
-
from typing import Dict, List, Optional
|
|
14
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
15
15
|
|
|
16
16
|
logger = logging.getLogger(__name__)
|
|
17
17
|
|
|
@@ -104,6 +104,10 @@ class OTelConfig:
|
|
|
104
104
|
default_factory=lambda: float(os.getenv("GENAI_CARBON_INTENSITY", "475.0"))
|
|
105
105
|
) # gCO2e/kWh
|
|
106
106
|
|
|
107
|
+
power_cost_per_kwh: float = field(
|
|
108
|
+
default_factory=lambda: float(os.getenv("GENAI_POWER_COST_PER_KWH", "0.12"))
|
|
109
|
+
) # USD per kWh - electricity cost for power consumption tracking
|
|
110
|
+
|
|
107
111
|
gpu_collection_interval: int = field(
|
|
108
112
|
default_factory=lambda: int(os.getenv("GENAI_GPU_COLLECTION_INTERVAL", "5"))
|
|
109
113
|
) # seconds - how often to collect GPU metrics and CO2 emissions
|
|
@@ -120,6 +124,20 @@ class OTelConfig:
|
|
|
120
124
|
default_factory=lambda: os.getenv("GENAI_ENABLE_CONTENT_CAPTURE", "false").lower() == "true"
|
|
121
125
|
)
|
|
122
126
|
|
|
127
|
+
# Custom pricing configuration for models not in llm_pricing.json
|
|
128
|
+
# Format: JSON string with same structure as llm_pricing.json
|
|
129
|
+
# Example: {"chat": {"custom-model": {"promptPrice": 0.001, "completionPrice": 0.002}}}
|
|
130
|
+
custom_pricing_json: Optional[str] = field(
|
|
131
|
+
default_factory=lambda: os.getenv("GENAI_CUSTOM_PRICING_JSON")
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Session and user tracking (Phase 4.1)
|
|
135
|
+
# Optional callable functions to extract session_id and user_id from requests
|
|
136
|
+
# Signature: (instance, args, kwargs) -> Optional[str]
|
|
137
|
+
# Example: lambda instance, args, kwargs: kwargs.get("metadata", {}).get("session_id")
|
|
138
|
+
session_id_extractor: Optional[Callable[[Any, Tuple, Dict], Optional[str]]] = None
|
|
139
|
+
user_id_extractor: Optional[Callable[[Any, Tuple, Dict], Optional[str]]] = None
|
|
140
|
+
|
|
123
141
|
|
|
124
142
|
import os
|
|
125
143
|
|
genai_otel/cost_calculator.py
CHANGED
|
@@ -13,10 +13,18 @@ class CostCalculator:
|
|
|
13
13
|
|
|
14
14
|
DEFAULT_PRICING_FILE = "llm_pricing.json"
|
|
15
15
|
|
|
16
|
-
def __init__(self):
|
|
17
|
-
"""Initializes the CostCalculator by loading pricing data from a JSON file.
|
|
16
|
+
def __init__(self, custom_pricing_json: Optional[str] = None):
|
|
17
|
+
"""Initializes the CostCalculator by loading pricing data from a JSON file.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
custom_pricing_json: Optional JSON string with custom model pricing.
|
|
21
|
+
Format: {"chat": {"model-name": {"promptPrice": 0.001, "completionPrice": 0.002}}}
|
|
22
|
+
Custom prices will be merged with default pricing, with custom taking precedence.
|
|
23
|
+
"""
|
|
18
24
|
self.pricing_data: Dict[str, Any] = {}
|
|
19
25
|
self._load_pricing()
|
|
26
|
+
if custom_pricing_json:
|
|
27
|
+
self._merge_custom_pricing(custom_pricing_json)
|
|
20
28
|
|
|
21
29
|
def _load_pricing(self):
|
|
22
30
|
"""Load pricing data from the JSON configuration file."""
|
|
@@ -60,6 +68,64 @@ class CostCalculator:
|
|
|
60
68
|
except Exception as e:
|
|
61
69
|
logger.error("An unexpected error occurred while loading pricing: %s", e, exc_info=True)
|
|
62
70
|
|
|
71
|
+
def _merge_custom_pricing(self, custom_pricing_json: str):
|
|
72
|
+
"""Merge custom pricing from JSON string into existing pricing data.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
custom_pricing_json: JSON string with custom model pricing.
|
|
76
|
+
Format: {"chat": {"model-name": {"promptPrice": 0.001, "completionPrice": 0.002}}}
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
custom_pricing = json.loads(custom_pricing_json)
|
|
80
|
+
|
|
81
|
+
if not isinstance(custom_pricing, dict):
|
|
82
|
+
logger.error(
|
|
83
|
+
"Custom pricing must be a JSON object/dict. Got: %s",
|
|
84
|
+
type(custom_pricing).__name__,
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
# Merge custom pricing into each category (chat, embeddings, images, audio)
|
|
89
|
+
for category, models in custom_pricing.items():
|
|
90
|
+
if category not in ["chat", "embeddings", "images", "audio"]:
|
|
91
|
+
logger.warning(
|
|
92
|
+
"Unknown pricing category '%s' in custom pricing. Valid categories: "
|
|
93
|
+
"chat, embeddings, images, audio",
|
|
94
|
+
category,
|
|
95
|
+
)
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
if not isinstance(models, dict):
|
|
99
|
+
logger.error(
|
|
100
|
+
"Custom pricing for category '%s' must be a dict. Got: %s",
|
|
101
|
+
category,
|
|
102
|
+
type(models).__name__,
|
|
103
|
+
)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# Initialize category if it doesn't exist
|
|
107
|
+
if category not in self.pricing_data:
|
|
108
|
+
self.pricing_data[category] = {}
|
|
109
|
+
|
|
110
|
+
# Merge models into the category
|
|
111
|
+
for model_name, pricing in models.items():
|
|
112
|
+
self.pricing_data[category][model_name] = pricing
|
|
113
|
+
logger.info(
|
|
114
|
+
"Added custom pricing for %s model '%s': %s",
|
|
115
|
+
category,
|
|
116
|
+
model_name,
|
|
117
|
+
pricing,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
except json.JSONDecodeError as e:
|
|
121
|
+
logger.error(
|
|
122
|
+
"Failed to decode custom pricing JSON: %s. Custom pricing will be ignored.", e
|
|
123
|
+
)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.error(
|
|
126
|
+
"An unexpected error occurred while merging custom pricing: %s", e, exc_info=True
|
|
127
|
+
)
|
|
128
|
+
|
|
63
129
|
def calculate_cost(
|
|
64
130
|
self,
|
|
65
131
|
model: str,
|
|
@@ -150,7 +216,7 @@ class CostCalculator:
|
|
|
150
216
|
model,
|
|
151
217
|
param_count,
|
|
152
218
|
pricing["promptPrice"],
|
|
153
|
-
pricing["completionPrice"]
|
|
219
|
+
pricing["completionPrice"],
|
|
154
220
|
)
|
|
155
221
|
else:
|
|
156
222
|
logger.debug("Pricing not found for chat model: %s", model)
|
|
@@ -319,14 +385,14 @@ class CostCalculator:
|
|
|
319
385
|
|
|
320
386
|
# First try explicit parameter count patterns (e.g., 135m, 7b, 70b)
|
|
321
387
|
# Matches: digits followed by optional decimal, then 'm' or 'b'
|
|
322
|
-
pattern = r
|
|
388
|
+
pattern = r"(\d+(?:\.\d+)?)(m|b)(?:\s|:|$|-)"
|
|
323
389
|
match = re.search(pattern, model_lower)
|
|
324
390
|
if match:
|
|
325
391
|
value = float(match.group(1))
|
|
326
392
|
unit = match.group(2)
|
|
327
|
-
if unit ==
|
|
393
|
+
if unit == "m":
|
|
328
394
|
return value / 1000 # Convert millions to billions
|
|
329
|
-
elif unit ==
|
|
395
|
+
elif unit == "b":
|
|
330
396
|
return value
|
|
331
397
|
|
|
332
398
|
# Fallback to common model size indicators for HuggingFace models
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Custom SpanExporter that enriches spans with cost attributes before export.
|
|
2
|
+
|
|
3
|
+
This exporter wraps another exporter (like OTLPSpanExporter) and adds cost
|
|
4
|
+
attributes to spans before passing them to the wrapped exporter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
11
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
12
|
+
|
|
13
|
+
from .cost_calculator import CostCalculator
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CostEnrichingSpanExporter(SpanExporter):
|
|
19
|
+
"""Wraps a SpanExporter and enriches spans with cost attributes before export.
|
|
20
|
+
|
|
21
|
+
This exporter:
|
|
22
|
+
1. Receives ReadableSpan objects from the SDK
|
|
23
|
+
2. Extracts model name and token usage from span attributes
|
|
24
|
+
3. Calculates cost using CostCalculator
|
|
25
|
+
4. Creates enriched span data with cost attributes
|
|
26
|
+
5. Exports to the wrapped exporter (e.g., OTLP)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self, wrapped_exporter: SpanExporter, cost_calculator: Optional[CostCalculator] = None
|
|
31
|
+
):
|
|
32
|
+
"""Initialize the cost enriching exporter.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
wrapped_exporter: The underlying exporter to send enriched spans to.
|
|
36
|
+
cost_calculator: CostCalculator instance to use for cost calculations.
|
|
37
|
+
If None, creates a new instance.
|
|
38
|
+
"""
|
|
39
|
+
self.wrapped_exporter = wrapped_exporter
|
|
40
|
+
self.cost_calculator = cost_calculator or CostCalculator()
|
|
41
|
+
logger.info(
|
|
42
|
+
f"CostEnrichingSpanExporter initialized, wrapping {type(wrapped_exporter).__name__}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
46
|
+
"""Export spans after enriching them with cost attributes.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
spans: Sequence of ReadableSpan objects to export.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
SpanExportResult from the wrapped exporter.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
# Enrich spans with cost attributes
|
|
56
|
+
enriched_spans = []
|
|
57
|
+
for span in spans:
|
|
58
|
+
enriched_span = self._enrich_span(span)
|
|
59
|
+
enriched_spans.append(enriched_span)
|
|
60
|
+
|
|
61
|
+
# Export to wrapped exporter
|
|
62
|
+
return self.wrapped_exporter.export(enriched_spans)
|
|
63
|
+
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error(f"Failed to export spans: {e}", exc_info=True)
|
|
66
|
+
return SpanExportResult.FAILURE
|
|
67
|
+
|
|
68
|
+
def _enrich_span(self, span: ReadableSpan) -> ReadableSpan:
|
|
69
|
+
"""Enrich a span with cost attributes if applicable.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
span: The original ReadableSpan.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A new ReadableSpan with cost attributes added (or the original if not applicable).
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
# Check if span has LLM-related attributes
|
|
79
|
+
if not span.attributes:
|
|
80
|
+
return span
|
|
81
|
+
|
|
82
|
+
attributes = dict(span.attributes) # Make a mutable copy
|
|
83
|
+
|
|
84
|
+
# Check for model name - support both GenAI and OpenInference conventions
|
|
85
|
+
model = (
|
|
86
|
+
attributes.get("gen_ai.request.model")
|
|
87
|
+
or attributes.get("llm.model_name")
|
|
88
|
+
or attributes.get("embedding.model_name")
|
|
89
|
+
)
|
|
90
|
+
if not model:
|
|
91
|
+
return span
|
|
92
|
+
|
|
93
|
+
# Skip if cost attributes are already present
|
|
94
|
+
if "gen_ai.usage.cost.total" in attributes:
|
|
95
|
+
logger.debug(f"Span '{span.name}' already has cost attributes, skipping enrichment")
|
|
96
|
+
return span
|
|
97
|
+
|
|
98
|
+
# Extract token usage - support GenAI, OpenInference, and legacy conventions
|
|
99
|
+
prompt_tokens = (
|
|
100
|
+
attributes.get("gen_ai.usage.prompt_tokens")
|
|
101
|
+
or attributes.get("gen_ai.usage.input_tokens")
|
|
102
|
+
or attributes.get("llm.token_count.prompt") # OpenInference
|
|
103
|
+
or 0
|
|
104
|
+
)
|
|
105
|
+
completion_tokens = (
|
|
106
|
+
attributes.get("gen_ai.usage.completion_tokens")
|
|
107
|
+
or attributes.get("gen_ai.usage.output_tokens")
|
|
108
|
+
or attributes.get("llm.token_count.completion") # OpenInference
|
|
109
|
+
or 0
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Skip if no tokens recorded
|
|
113
|
+
if prompt_tokens == 0 and completion_tokens == 0:
|
|
114
|
+
return span
|
|
115
|
+
|
|
116
|
+
# Get call type - support both GenAI and OpenInference conventions
|
|
117
|
+
span_kind = attributes.get("openinference.span.kind", "").upper()
|
|
118
|
+
call_type = attributes.get("gen_ai.operation.name") or span_kind.lower() or "chat"
|
|
119
|
+
|
|
120
|
+
# Map operation names to call types
|
|
121
|
+
call_type_mapping = {
|
|
122
|
+
"chat": "chat",
|
|
123
|
+
"completion": "chat",
|
|
124
|
+
"embedding": "embedding",
|
|
125
|
+
"embeddings": "embedding",
|
|
126
|
+
"text_generation": "chat",
|
|
127
|
+
"image_generation": "image",
|
|
128
|
+
"audio": "audio",
|
|
129
|
+
"llm": "chat",
|
|
130
|
+
"chain": "chat",
|
|
131
|
+
"retriever": "embedding",
|
|
132
|
+
"reranker": "embedding",
|
|
133
|
+
"tool": "chat",
|
|
134
|
+
"agent": "chat",
|
|
135
|
+
}
|
|
136
|
+
normalized_call_type = call_type_mapping.get(str(call_type).lower(), "chat")
|
|
137
|
+
|
|
138
|
+
# Calculate cost
|
|
139
|
+
usage = {
|
|
140
|
+
"prompt_tokens": int(prompt_tokens),
|
|
141
|
+
"completion_tokens": int(completion_tokens),
|
|
142
|
+
"total_tokens": int(prompt_tokens) + int(completion_tokens),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
cost_info = self.cost_calculator.calculate_granular_cost(
|
|
146
|
+
model=str(model),
|
|
147
|
+
usage=usage,
|
|
148
|
+
call_type=normalized_call_type,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if cost_info and cost_info.get("total", 0.0) > 0:
|
|
152
|
+
# Add cost attributes to the mutable copy
|
|
153
|
+
attributes["gen_ai.usage.cost.total"] = cost_info["total"]
|
|
154
|
+
|
|
155
|
+
if cost_info.get("prompt", 0.0) > 0:
|
|
156
|
+
attributes["gen_ai.usage.cost.prompt"] = cost_info["prompt"]
|
|
157
|
+
if cost_info.get("completion", 0.0) > 0:
|
|
158
|
+
attributes["gen_ai.usage.cost.completion"] = cost_info["completion"]
|
|
159
|
+
|
|
160
|
+
logger.info(
|
|
161
|
+
f"Enriched span '{span.name}' with cost: {cost_info['total']:.6f} USD "
|
|
162
|
+
f"for model {model} ({usage['total_tokens']} tokens)"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Create a new ReadableSpan with enriched attributes
|
|
166
|
+
# ReadableSpan is a NamedTuple, so we need to replace it
|
|
167
|
+
from opentelemetry.sdk.trace import ReadableSpan as RS
|
|
168
|
+
|
|
169
|
+
enriched_span = RS(
|
|
170
|
+
name=span.name,
|
|
171
|
+
context=span.context,
|
|
172
|
+
kind=span.kind,
|
|
173
|
+
parent=span.parent,
|
|
174
|
+
start_time=span.start_time,
|
|
175
|
+
end_time=span.end_time,
|
|
176
|
+
status=span.status,
|
|
177
|
+
attributes=attributes, # Use enriched attributes
|
|
178
|
+
events=span.events,
|
|
179
|
+
links=span.links,
|
|
180
|
+
resource=span.resource,
|
|
181
|
+
instrumentation_scope=span.instrumentation_scope,
|
|
182
|
+
)
|
|
183
|
+
return enriched_span
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.warning(
|
|
187
|
+
f"Failed to enrich span '{getattr(span, 'name', 'unknown')}' with cost: {e}",
|
|
188
|
+
exc_info=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return span
|
|
192
|
+
|
|
193
|
+
def shutdown(self) -> None:
|
|
194
|
+
"""Shutdown the wrapped exporter."""
|
|
195
|
+
logger.info("CostEnrichingSpanExporter shutting down")
|
|
196
|
+
self.wrapped_exporter.shutdown()
|
|
197
|
+
|
|
198
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
199
|
+
"""Force flush the wrapped exporter.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
timeout_millis: Timeout in milliseconds.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
True if flush succeeded.
|
|
206
|
+
"""
|
|
207
|
+
return self.wrapped_exporter.force_flush(timeout_millis)
|