diff --git a/.flake8 b/.flake8 index dd55df608a..84b003d4b0 100644 --- a/.flake8 +++ b/.flake8 @@ -16,6 +16,7 @@ exclude = target __pycache__ exporter/opentelemetry-exporter-jaeger/src/opentelemetry/exporter/jaeger/gen/ + exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/gen/ exporter/opentelemetry-exporter-jaeger/build/* docs/examples/opentelemetry-example-app/src/opentelemetry_example_app/grpc/gen/ docs/examples/opentelemetry-example-app/build/* diff --git a/CHANGELOG.md b/CHANGELOG.md index cbadf6ae16..ef3b5526c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#1413](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1413)) - `opentelemetry-instrumentation-pyramid` Add support for regular expression matching and sanitization of HTTP headers. ([#1414](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1414)) +- Add metric exporter for Prometheus Remote Write + ([#1359](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1359)) ### Fixed @@ -62,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add metric instrumentation in starlette ([#1327](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1327)) + ### Fixed - `opentelemetry-instrumentation-boto3sqs` Make propagation compatible with other SQS instrumentations, add 'messaging.url' span attribute, and fix missing package dependencies. diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/README.rst b/exporter/opentelemetry-exporter-prometheus-remote-write/README.rst new file mode 100644 index 0000000000..6ed5c5ebcf --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/README.rst @@ -0,0 +1,29 @@ +OpenTelemetry Prometheus Remote Write Exporter +============================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-exporter-prometheus-remote-write.svg + :target: https://pypi.org/project/opentelemetry-exporter-prometheus-remote-write/ + +This package contains an exporter to send metrics from the OpenTelemetry Python SDK directly to a Prometheus Remote Write integrated backend +(such as Cortex or Thanos) without having to run an instance of the Prometheus server. + + +Installation +------------ + +:: + + pip install opentelemetry-exporter-prometheus-remote-write + + +.. _OpenTelemetry: https://github.com/open-telemetry/opentelemetry-python/ +.. _Prometheus Remote Write integrated backend: https://prometheus.io/docs/operating/integrations/ + + +References +---------- + +* `OpenTelemetry Project `_ +* `Prometheus Remote Write Integration `_ diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/example/Dockerfile b/exporter/opentelemetry-exporter-prometheus-remote-write/example/Dockerfile new file mode 100644 index 0000000000..f3fca0d568 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/example/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.8 + +RUN apt-get update -y && apt-get install libsnappy-dev -y + +WORKDIR /code +COPY . . + +RUN pip install -e . +RUN pip install -r ./examples/requirements.txt + +CMD ["python", "./examples/sampleapp.py"] diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/example/README.md b/exporter/opentelemetry-exporter-prometheus-remote-write/example/README.md new file mode 100644 index 0000000000..72c60015c4 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/example/README.md @@ -0,0 +1,42 @@ +# Prometheus Remote Write Exporter Example +This example uses [Docker Compose](https://docs.docker.com/compose/) to set up: + +1. A Python program that creates 5 instruments with 5 unique +aggregators and a randomized load generator +2. An instance of [Cortex](https://cortexmetrics.io/) to receive the metrics +data +3. An instance of [Grafana](https://grafana.com/) to visualizse the exported +data + +## Requirements +* Have Docker Compose [installed](https://docs.docker.com/compose/install/) + +*Users do not need to install Python as the app will be run in the Docker Container* + +## Instructions +1. Run `docker-compose up -d` in the the `examples/` directory + +The `-d` flag causes all services to run in detached mode and frees up your +terminal session. This also causes no logs to show up. Users can attach themselves to the service's logs manually using `docker logs ${CONTAINER_ID} --follow` + +2. Log into the Grafana instance at [http://localhost:3000](http://localhost:3000) + * login credentials are `username: admin` and `password: admin` + * There may be an additional screen on setting a new password. This can be skipped and is optional + +3. Navigate to the `Data Sources` page + * Look for a gear icon on the left sidebar and select `Data Sources` + +4. Add a new Prometheus Data Source + * Use `http://cortex:9009/api/prom` as the URL + * (OPTIONAl) set the scrape interval to `2s` to make updates appear quickly + * click `Save & Test` + +5. Go to `Metrics Explore` to query metrics + * Look for a compass icon on the left sidebar + * click `Metrics` for a dropdown list of all the available metrics + * (OPTIONAL) Adjust time range by clicking the `Last 6 hours` button on the upper right side of the graph + * (OPTIONAL) Set up auto-refresh by selecting an option under the dropdown next to the refresh button on the upper right side of the graph + * Click the refresh button and data should show up on the graph + +6. Shutdown the services when finished + * Run `docker-compose down` in the examples directory diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/example/cortex-config.yml b/exporter/opentelemetry-exporter-prometheus-remote-write/example/cortex-config.yml new file mode 100644 index 0000000000..e3451b94c2 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/example/cortex-config.yml @@ -0,0 +1,101 @@ +# This Cortex Config is copied from the Cortex Project documentation +# Source: https://github.com/cortexproject/cortex/blob/master/docs/configuration/single-process-config.yaml + +# Configuration for running Cortex in single-process mode. +# This configuration should not be used in production. +# It is only for getting started and development. + +# Disable the requirement that every request to Cortex has a +# X-Scope-OrgID header. `fake` will be substituted in instead. +# pylint: skip-file +auth_enabled: false + +server: + http_listen_port: 9009 + + # Configure the server to allow messages up to 100MB. + grpc_server_max_recv_msg_size: 104857600 + grpc_server_max_send_msg_size: 104857600 + grpc_server_max_concurrent_streams: 1000 + +distributor: + shard_by_all_labels: true + pool: + health_check_ingesters: true + +ingester_client: + grpc_client_config: + # Configure the client to allow messages up to 100MB. + max_recv_msg_size: 104857600 + max_send_msg_size: 104857600 + use_gzip_compression: true + +ingester: + # We want our ingesters to flush chunks at the same time to optimise + # deduplication opportunities. + spread_flushes: true + chunk_age_jitter: 0 + + walconfig: + wal_enabled: true + recover_from_wal: true + wal_dir: /tmp/cortex/wal + + lifecycler: + # The address to advertise for this ingester. Will be autodiscovered by + # looking up address on eth0 or en0; can be specified if this fails. + # address: 127.0.0.1 + + # We want to start immediately and flush on shutdown. + join_after: 0 + min_ready_duration: 0s + final_sleep: 0s + num_tokens: 512 + tokens_file_path: /tmp/cortex/wal/tokens + + # Use an in memory ring store, so we don't need to launch a Consul. + ring: + kvstore: + store: inmemory + replication_factor: 1 + +# Use local storage - BoltDB for the index, and the filesystem +# for the chunks. +schema: + configs: + - from: 2019-07-29 + store: boltdb + object_store: filesystem + schema: v10 + index: + prefix: index_ + period: 1w + +storage: + boltdb: + directory: /tmp/cortex/index + + filesystem: + directory: /tmp/cortex/chunks + + delete_store: + store: boltdb + +purger: + object_store_type: filesystem + +frontend_worker: + # Configure the frontend worker in the querier to match worker count + # to max_concurrent on the queriers. + match_max_concurrent: true + +# Configure the ruler to scan the /tmp/cortex/rules directory for prometheus +# rules: https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/#recording-rules +ruler: + enable_api: true + enable_sharding: false + storage: + type: local + local: + directory: /tmp/cortex/rules + diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/example/docker-compose.yml b/exporter/opentelemetry-exporter-prometheus-remote-write/example/docker-compose.yml new file mode 100644 index 0000000000..61e6f4981e --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/example/docker-compose.yml @@ -0,0 +1,33 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +version: "3.8" + +services: + cortex: + image: quay.io/cortexproject/cortex:v1.5.0 + command: + - -config.file=./config/cortex-config.yml + volumes: + - ./cortex-config.yml:/config/cortex-config.yml:ro + ports: + - 9009:9009 + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + sample_app: + build: + context: ../ + dockerfile: ./examples/Dockerfile diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/example/requirements.txt b/exporter/opentelemetry-exporter-prometheus-remote-write/example/requirements.txt new file mode 100644 index 0000000000..f049aac258 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/example/requirements.txt @@ -0,0 +1,7 @@ +psutil +protobuf>=3.13.0 +requests>=2.25.0 +python-snappy>=0.5.4 +opentelemetry-api +opentelemetry-sdk +opentelemetry-proto diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/example/sampleapp.py b/exporter/opentelemetry-exporter-prometheus-remote-write/example/sampleapp.py new file mode 100644 index 0000000000..40e217d22c --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/example/sampleapp.py @@ -0,0 +1,114 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import random +import sys +import time +from logging import INFO + +import psutil + +from opentelemetry import metrics +from opentelemetry.exporter.prometheus_remote_write import ( + PrometheusRemoteWriteMetricsExporter, +) +from opentelemetry.metrics import Observation +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader + +logging.basicConfig(stream=sys.stdout, level=logging.INFO) +logger = logging.getLogger(__name__) + + +testing_labels = {"environment": "testing"} + +exporter = PrometheusRemoteWriteMetricsExporter( + endpoint="http://cortex:9009/api/prom/push", + headers={"X-Scope-Org-ID": "5"}, +) +reader = PeriodicExportingMetricReader(exporter, 1000) +provider = MeterProvider(metric_readers=[reader]) +metrics.set_meter_provider(provider) +meter = metrics.get_meter(__name__) + + +# Callback to gather cpu usage +def get_cpu_usage_callback(observer): + for (number, percent) in enumerate(psutil.cpu_percent(percpu=True)): + labels = {"cpu_number": str(number)} + yield Observation(percent, labels) + + +# Callback to gather RAM usage +def get_ram_usage_callback(observer): + ram_percent = psutil.virtual_memory().percent + yield Observation(ram_percent, {}) + + +requests_counter = meter.create_counter( + name="requests", + description="number of requests", + unit="1", +) + +request_min_max = meter.create_counter( + name="requests_min_max", + description="min max sum count of requests", + unit="1", +) + +request_last_value = meter.create_counter( + name="requests_last_value", + description="last value number of requests", + unit="1", +) + +requests_active = meter.create_up_down_counter( + name="requests_active", + description="number of active requests", + unit="1", +) + +meter.create_observable_counter( + callbacks=[get_ram_usage_callback], + name="ram_usage", + description="ram usage", + unit="1", +) + +meter.create_observable_up_down_counter( + callbacks=[get_cpu_usage_callback], + name="cpu_percent", + description="per-cpu usage", + unit="1", +) + +request_latency = meter.create_histogram("request_latency") + +# Load generator +num = random.randint(0, 1000) +while True: + # counters + requests_counter.add(num % 131 + 200, testing_labels) + request_min_max.add(num % 181 + 200, testing_labels) + request_last_value.add(num % 101 + 200, testing_labels) + + # updown counter + requests_active.add(num % 7231 + 200, testing_labels) + + request_latency.record(num % 92, testing_labels) + logger.log(level=INFO, msg="completed metrics collection cycle") + time.sleep(1) + num += 9791 diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/proto/.gitignore b/exporter/opentelemetry-exporter-prometheus-remote-write/proto/.gitignore new file mode 100644 index 0000000000..25138d1941 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/proto/.gitignore @@ -0,0 +1 @@ +opentelemetry diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/proto/README.md b/exporter/opentelemetry-exporter-prometheus-remote-write/proto/README.md new file mode 100644 index 0000000000..23fdaa392d --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/proto/README.md @@ -0,0 +1,3 @@ +## Instructions +1. Install protobuf tools. Can use your package manager or download from [GitHub](https://github.com/protocolbuffers/protobuf/releases/tag/v21.7) +2. Run `generate-proto-py.sh` from inside the `proto/` directory diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/proto/generate-proto-py.sh b/exporter/opentelemetry-exporter-prometheus-remote-write/proto/generate-proto-py.sh new file mode 100755 index 0000000000..3cde0bd1ac --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/proto/generate-proto-py.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +PROM_VERSION=v2.39.0 +PROTO_VERSION=v1.3.2 + +# SRC_DIR is from protoc perspective. ie its the destination for our checkouts/clones +SRC_DIR=opentelemetry/exporter/prometheus_remote_write/gen/ +DST_DIR=../src/opentelemetry/exporter/prometheus_remote_write/gen/ + +#TODO: +# Check that black & protoc are installed properly +echo "Creating our destination directory" +mkdir -p ${SRC_DIR}/gogoproto + +# Clone prometheus +echo "Grabbing Prometheus protobuf files" +git clone --filter=blob:none --sparse https://github.com/prometheus/prometheus.git +cd prometheus +git checkout ${PROM_VERSION} +git sparse-checkout set prompb +cd .. + + +# We also need gogo.proto which is in the protobuf Repo +# Could also try to pull this locally from the install location of protobuf +# but that will be harder in a platform agnostic way. +echo "Grabbing gogo.proto" +git clone --filter=blob:none --sparse https://github.com/gogo/protobuf.git +cd protobuf +git checkout ${PROTO_VERSION} +git sparse-checkout set /gogoproto/gogo.proto +cd .. + +# Move the proto files into our structure +echo "Moving proto files to ${SRC_DIR}" +cp prometheus/prompb/remote.proto prometheus/prompb/types.proto ${SRC_DIR} +cp protobuf/gogoproto/gogo.proto ${SRC_DIR}/gogoproto/ + + +# A bit of a hack, but we need to fix the imports to fit the python structure. +# using sed to find the 3 files and point them at each other using OUR structure +echo "Fixing imports" +sed -i 's/import "types.proto";/import "opentelemetry\/exporter\/prometheus_remote_write\/gen\/types.proto";/' ${SRC_DIR}/remote.proto +sed -i 's/import "gogoproto\/gogo.proto";/import "opentelemetry\/exporter\/prometheus_remote_write\/gen\/gogoproto\/gogo.proto";/' ${SRC_DIR}/remote.proto +sed -i 's/import "gogoproto\/gogo.proto";/import "opentelemetry\/exporter\/prometheus_remote_write\/gen\/gogoproto\/gogo.proto";/' ${SRC_DIR}/types.proto + + +# Cleanup the repos +echo "Removing clones..." +rm -rf protobuf prometheus + +# Used libprotoc 3.21.1 & protoc 21.7 +echo "Compiling proto files to Python" +protoc -I . --python_out=../src ${SRC_DIR}/gogoproto/gogo.proto ${SRC_DIR}/remote.proto ${SRC_DIR}/types.proto + +echo "Running formatting on the generated files" +../../../scripts/eachdist.py format --path $PWD/.. diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/pyproject.toml b/exporter/opentelemetry-exporter-prometheus-remote-write/pyproject.toml new file mode 100644 index 0000000000..49ae48d397 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + + +[project] +name = "opentelemetry-exporter-prometheus-remote-write" +dynamic = ["version"] +description = "Prometheus Remote Write Metrics Exporter for OpenTelemetry" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.7" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", +] +dependencies = [ + "protobuf ~= 4.21", + "requests ~= 2.28", + "opentelemetry-api ~= 1.12", + "opentelemetry-sdk ~= 1.12", + "python-snappy ~= 0.6", +] + +[project.optional-dependencies] +test = [] + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/exporter/opentelemetry-exporter-prometheus-remote-write" + +[tool.hatch.version] +path = "src/opentelemetry/exporter/prometheus_remote_write/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/__init__.py b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/__init__.py new file mode 100644 index 0000000000..0adfcb6d33 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/__init__.py @@ -0,0 +1,414 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import re +from collections import defaultdict +from itertools import chain +from typing import Dict, Sequence + +import requests +import snappy + +from opentelemetry.exporter.prometheus_remote_write.gen.remote_pb2 import ( # pylint: disable=no-name-in-module + WriteRequest, +) +from opentelemetry.exporter.prometheus_remote_write.gen.types_pb2 import ( # pylint: disable=no-name-in-module + Label, + Sample, + TimeSeries, +) +from opentelemetry.sdk.metrics import Counter +from opentelemetry.sdk.metrics import Histogram as ClientHistogram +from opentelemetry.sdk.metrics import ( + ObservableCounter, + ObservableGauge, + ObservableUpDownCounter, + UpDownCounter, +) +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + Gauge, + Histogram, + Metric, + MetricExporter, + MetricExportResult, + MetricsData, + Sum, +) + +logger = logging.getLogger(__name__) + +PROMETHEUS_NAME_REGEX = re.compile(r"^\d|[^\w:]") +PROMETHEUS_LABEL_REGEX = re.compile(r"^\d|[^\w]") +UNDERSCORE_REGEX = re.compile(r"_+") + + +class PrometheusRemoteWriteMetricsExporter(MetricExporter): + """ + Prometheus remote write metric exporter for OpenTelemetry. + + Args: + endpoint: url where data will be sent (Required) + basic_auth: username and password for authentication (Optional) + headers: additional headers for remote write request (Optional) + timeout: timeout for remote write requests in seconds, defaults to 30 (Optional) + proxies: dict mapping request proxy protocols to proxy urls (Optional) + tls_config: configuration for remote write TLS settings (Optional) + """ + + def __init__( + self, + endpoint: str, + basic_auth: Dict = None, + headers: Dict = None, + timeout: int = 30, + tls_config: Dict = None, + proxies: Dict = None, + resources_as_labels: bool = True, + preferred_temporality: Dict[type, AggregationTemporality] = None, + preferred_aggregation: Dict = None, + ): + self.endpoint = endpoint + self.basic_auth = basic_auth + self.headers = headers + self.timeout = timeout + self.tls_config = tls_config + self.proxies = proxies + self.resources_as_labels = resources_as_labels + + if not preferred_temporality: + preferred_temporality = { + Counter: AggregationTemporality.CUMULATIVE, + UpDownCounter: AggregationTemporality.CUMULATIVE, + ClientHistogram: AggregationTemporality.CUMULATIVE, + ObservableCounter: AggregationTemporality.CUMULATIVE, + ObservableUpDownCounter: AggregationTemporality.CUMULATIVE, + ObservableGauge: AggregationTemporality.CUMULATIVE, + } + + super().__init__(preferred_temporality, preferred_aggregation) + + @property + def endpoint(self): + return self._endpoint + + @endpoint.setter + def endpoint(self, endpoint: str): + if endpoint == "": + raise ValueError("endpoint required") + self._endpoint = endpoint + + @property + def basic_auth(self): + return self._basic_auth + + @basic_auth.setter + def basic_auth(self, basic_auth: Dict): + if basic_auth: + if "username" not in basic_auth: + raise ValueError("username required in basic_auth") + if "password_file" in basic_auth: + if "password" in basic_auth: + raise ValueError( + "basic_auth cannot contain password and password_file" + ) + with open( # pylint: disable=unspecified-encoding + basic_auth["password_file"] + ) as file: + basic_auth["password"] = file.readline().strip() + elif "password" not in basic_auth: + raise ValueError("password required in basic_auth") + self._basic_auth = basic_auth + + @property + def timeout(self): + return self._timeout + + @timeout.setter + def timeout(self, timeout: int): + if timeout <= 0: + raise ValueError("timeout must be greater than 0") + self._timeout = timeout + + @property + def tls_config(self): + return self._tls_config + + @tls_config.setter + def tls_config(self, tls_config: Dict): + if tls_config: + new_config = {} + if "ca_file" in tls_config: + new_config["ca_file"] = tls_config["ca_file"] + if "cert_file" in tls_config and "key_file" in tls_config: + new_config["cert_file"] = tls_config["cert_file"] + new_config["key_file"] = tls_config["key_file"] + elif "cert_file" in tls_config or "key_file" in tls_config: + raise ValueError( + "tls_config requires both cert_file and key_file" + ) + if "insecure_skip_verify" in tls_config: + new_config["insecure_skip_verify"] = tls_config[ + "insecure_skip_verify" + ] + self._tls_config = tls_config + + @property + def proxies(self): + return self._proxies + + @proxies.setter + def proxies(self, proxies: Dict): + self._proxies = proxies + + @property + def headers(self): + return self._headers + + @headers.setter + def headers(self, headers: Dict): + self._headers = headers + + def export( + self, + metrics_data: MetricsData, + timeout_millis: float = 10_000, + **kwargs, + ) -> MetricExportResult: + if not metrics_data: + return MetricExportResult.SUCCESS + timeseries = self._translate_data(metrics_data) + if not timeseries: + logger.error( + "All records contain unsupported aggregators, export aborted" + ) + return MetricExportResult.FAILURE + message = self._build_message(timeseries) + headers = self._build_headers() + return self._send_message(message, headers) + + def _translate_data(self, data: MetricsData) -> Sequence[TimeSeries]: + rw_timeseries = [] + + for resource_metrics in data.resource_metrics: + resource = resource_metrics.resource + # OTLP Data model suggests combining some attrs into job/instance + # Should we do that here? + if self.resources_as_labels: + resource_labels = [ + (n, str(v)) for n, v in resource.attributes.items() + ] + else: + resource_labels = [] + # Scope name/version probably not too useful from a labeling perspective + for scope_metrics in resource_metrics.scope_metrics: + for metric in scope_metrics.metrics: + rw_timeseries.extend( + self._parse_metric(metric, resource_labels) + ) + return rw_timeseries + + def _parse_metric( + self, metric: Metric, resource_labels: Sequence + ) -> Sequence[TimeSeries]: + """ + Parses the Metric & lower objects, then converts the output into + OM TimeSeries. Returns a List of TimeSeries objects based on one Metric + """ + + # Create the metric name, will be a label later + if metric.unit: + # Prom. naming guidelines add unit to the name + name = f"{metric.name}_{metric.unit}" + else: + name = metric.name + + # datapoints have attributes associated with them. these would be sent + # to RW as different metrics: name & labels is a unique time series + sample_sets = defaultdict(list) + if isinstance(metric.data, (Gauge, Sum)): + for dp in metric.data.data_points: + attrs, sample = self._parse_data_point(dp, name) + sample_sets[attrs].append(sample) + elif isinstance(metric.data, Histogram): + for dp in metric.data.data_points: + dp_result = self._parse_histogram_data_point(dp, name) + for attrs, sample in dp_result: + sample_sets[attrs].append(sample) + else: + logger.warning("Unsupported Metric Type: %s", type(metric.data)) + return [] + return self._convert_to_timeseries(sample_sets, resource_labels) + + def _convert_to_timeseries( + self, sample_sets: Sequence[tuple], resource_labels: Sequence + ) -> Sequence[TimeSeries]: + timeseries = [] + for labels, samples in sample_sets.items(): + ts = TimeSeries() + for label_name, label_value in chain(resource_labels, labels): + # Previous implementation did not str() the names... + ts.labels.append(self._label(label_name, str(label_value))) + for value, timestamp in samples: + ts.samples.append(self._sample(value, timestamp)) + timeseries.append(ts) + return timeseries + + @staticmethod + def _sample(value: int, timestamp: int) -> Sample: + sample = Sample() + sample.value = value + sample.timestamp = timestamp + return sample + + def _label(self, name: str, value: str) -> Label: + label = Label() + label.name = self._sanitize_string(name, "label") + label.value = value + return label + + @staticmethod + def _sanitize_string(string: str, type_: str) -> str: + # I Think Prometheus requires names to NOT start with a number this + # would not catch that, but do cover the other cases. The naming rules + # don't explicit say this, but the supplied regex implies it. + # Got a little weird trying to do substitution with it, but can be + # fixed if we allow numeric beginnings to metric names + if type_ == "name": + sanitized = PROMETHEUS_NAME_REGEX.sub("_", string) + elif type_ == "label": + sanitized = PROMETHEUS_LABEL_REGEX.sub("_", string) + else: + raise TypeError(f"Unsupported string type: {type_}") + + # Remove consecutive underscores + # TODO: Unfortunately this clobbbers __name__ + # sanitized = UNDERSCORE_REGEX.sub("_",sanitized) + + return sanitized + + def _parse_histogram_data_point(self, data_point, name): + + sample_attr_pairs = [] + + base_attrs = list(data_point.attributes.items()) + timestamp = data_point.time_unix_nano // 1_000_000 + + def handle_bucket(value, bound=None, name_override=None): + # Metric Level attributes + the bucket boundary attribute + name + ts_attrs = base_attrs.copy() + ts_attrs.append( + ( + "__name__", + self._sanitize_string(name_override or name, "name"), + ) + ) + if bound: + ts_attrs.append(("le", str(bound))) + # Value is count of values in each bucket + ts_sample = (value, timestamp) + return tuple(ts_attrs), ts_sample + + for bound_pos, bound in enumerate(data_point.explicit_bounds): + sample_attr_pairs.append( + handle_bucket(data_point.bucket_counts[bound_pos], bound) + ) + + # Add the last label for implicit +inf bucket + sample_attr_pairs.append( + handle_bucket(data_point.bucket_counts[-1], bound="+Inf") + ) + + # Lastly, add series for count & sum + sample_attr_pairs.append( + handle_bucket(data_point.sum, name_override=f"{name}_sum") + ) + sample_attr_pairs.append( + handle_bucket(data_point.count, name_override=f"{name}_count") + ) + return sample_attr_pairs + + def _parse_data_point(self, data_point, name=None): + + attrs = tuple(data_point.attributes.items()) + ( + ("__name__", self._sanitize_string(name, "name")), + ) + sample = (data_point.value, (data_point.time_unix_nano // 1_000_000)) + return attrs, sample + + @staticmethod + def _build_message(timeseries: Sequence[TimeSeries]) -> bytes: + write_request = WriteRequest() + write_request.timeseries.extend(timeseries) + serialized_message = write_request.SerializeToString() + return snappy.compress(serialized_message) + + def _build_headers(self) -> Dict: + headers = { + "Content-Encoding": "snappy", + "Content-Type": "application/x-protobuf", + "X-Prometheus-Remote-Write-Version": "0.1.0", + } + if self.headers: + for header_name, header_value in self.headers.items(): + headers[header_name] = header_value + return headers + + def _send_message( + self, message: bytes, headers: Dict + ) -> MetricExportResult: + auth = None + if self.basic_auth: + auth = (self.basic_auth["username"], self.basic_auth["password"]) + + cert = None + verify = True + if self.tls_config: + if "ca_file" in self.tls_config: + verify = self.tls_config["ca_file"] + elif "insecure_skip_verify" in self.tls_config: + verify = self.tls_config["insecure_skip_verify"] + + if ( + "cert_file" in self.tls_config + and "key_file" in self.tls_config + ): + cert = ( + self.tls_config["cert_file"], + self.tls_config["key_file"], + ) + try: + response = requests.post( + self.endpoint, + data=message, + headers=headers, + auth=auth, + timeout=self.timeout, + proxies=self.proxies, + cert=cert, + verify=verify, + ) + if not response.ok: + response.raise_for_status() + except requests.exceptions.RequestException as err: + logger.error("Export POST request failed with reason: %s", err) + return MetricExportResult.FAILURE + return MetricExportResult.SUCCESS + + def force_flush(self, timeout_millis: float = 10_000) -> bool: + return True + + def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: + pass diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/gen/gogoproto/gogo_pb2.py b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/gen/gogoproto/gogo_pb2.py new file mode 100644 index 0000000000..d5cce2a857 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/gen/gogoproto/gogo_pb2.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: opentelemetry/exporter/prometheus_remote_write/gen/gogoproto/gogo.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import ( + descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2, +) + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\nGopentelemetry/exporter/prometheus_remote_write/gen/gogoproto/gogo.proto\x12\tgogoproto\x1a google/protobuf/descriptor.proto:;\n\x13goproto_enum_prefix\x12\x1c.google.protobuf.EnumOptions\x18\xb1\xe4\x03 \x01(\x08:=\n\x15goproto_enum_stringer\x12\x1c.google.protobuf.EnumOptions\x18\xc5\xe4\x03 \x01(\x08:5\n\renum_stringer\x12\x1c.google.protobuf.EnumOptions\x18\xc6\xe4\x03 \x01(\x08:7\n\x0f\x65num_customname\x12\x1c.google.protobuf.EnumOptions\x18\xc7\xe4\x03 \x01(\t:0\n\x08\x65numdecl\x12\x1c.google.protobuf.EnumOptions\x18\xc8\xe4\x03 \x01(\x08:A\n\x14\x65numvalue_customname\x12!.google.protobuf.EnumValueOptions\x18\xd1\x83\x04 \x01(\t:;\n\x13goproto_getters_all\x12\x1c.google.protobuf.FileOptions\x18\x99\xec\x03 \x01(\x08:?\n\x17goproto_enum_prefix_all\x12\x1c.google.protobuf.FileOptions\x18\x9a\xec\x03 \x01(\x08:<\n\x14goproto_stringer_all\x12\x1c.google.protobuf.FileOptions\x18\x9b\xec\x03 \x01(\x08:9\n\x11verbose_equal_all\x12\x1c.google.protobuf.FileOptions\x18\x9c\xec\x03 \x01(\x08:0\n\x08\x66\x61\x63\x65_all\x12\x1c.google.protobuf.FileOptions\x18\x9d\xec\x03 \x01(\x08:4\n\x0cgostring_all\x12\x1c.google.protobuf.FileOptions\x18\x9e\xec\x03 \x01(\x08:4\n\x0cpopulate_all\x12\x1c.google.protobuf.FileOptions\x18\x9f\xec\x03 \x01(\x08:4\n\x0cstringer_all\x12\x1c.google.protobuf.FileOptions\x18\xa0\xec\x03 \x01(\x08:3\n\x0bonlyone_all\x12\x1c.google.protobuf.FileOptions\x18\xa1\xec\x03 \x01(\x08:1\n\tequal_all\x12\x1c.google.protobuf.FileOptions\x18\xa5\xec\x03 \x01(\x08:7\n\x0f\x64\x65scription_all\x12\x1c.google.protobuf.FileOptions\x18\xa6\xec\x03 \x01(\x08:3\n\x0btestgen_all\x12\x1c.google.protobuf.FileOptions\x18\xa7\xec\x03 \x01(\x08:4\n\x0c\x62\x65nchgen_all\x12\x1c.google.protobuf.FileOptions\x18\xa8\xec\x03 \x01(\x08:5\n\rmarshaler_all\x12\x1c.google.protobuf.FileOptions\x18\xa9\xec\x03 \x01(\x08:7\n\x0funmarshaler_all\x12\x1c.google.protobuf.FileOptions\x18\xaa\xec\x03 \x01(\x08:<\n\x14stable_marshaler_all\x12\x1c.google.protobuf.FileOptions\x18\xab\xec\x03 \x01(\x08:1\n\tsizer_all\x12\x1c.google.protobuf.FileOptions\x18\xac\xec\x03 \x01(\x08:A\n\x19goproto_enum_stringer_all\x12\x1c.google.protobuf.FileOptions\x18\xad\xec\x03 \x01(\x08:9\n\x11\x65num_stringer_all\x12\x1c.google.protobuf.FileOptions\x18\xae\xec\x03 \x01(\x08:<\n\x14unsafe_marshaler_all\x12\x1c.google.protobuf.FileOptions\x18\xaf\xec\x03 \x01(\x08:>\n\x16unsafe_unmarshaler_all\x12\x1c.google.protobuf.FileOptions\x18\xb0\xec\x03 \x01(\x08:B\n\x1agoproto_extensions_map_all\x12\x1c.google.protobuf.FileOptions\x18\xb1\xec\x03 \x01(\x08:@\n\x18goproto_unrecognized_all\x12\x1c.google.protobuf.FileOptions\x18\xb2\xec\x03 \x01(\x08:8\n\x10gogoproto_import\x12\x1c.google.protobuf.FileOptions\x18\xb3\xec\x03 \x01(\x08:6\n\x0eprotosizer_all\x12\x1c.google.protobuf.FileOptions\x18\xb4\xec\x03 \x01(\x08:3\n\x0b\x63ompare_all\x12\x1c.google.protobuf.FileOptions\x18\xb5\xec\x03 \x01(\x08:4\n\x0ctypedecl_all\x12\x1c.google.protobuf.FileOptions\x18\xb6\xec\x03 \x01(\x08:4\n\x0c\x65numdecl_all\x12\x1c.google.protobuf.FileOptions\x18\xb7\xec\x03 \x01(\x08:<\n\x14goproto_registration\x12\x1c.google.protobuf.FileOptions\x18\xb8\xec\x03 \x01(\x08:7\n\x0fmessagename_all\x12\x1c.google.protobuf.FileOptions\x18\xb9\xec\x03 \x01(\x08:=\n\x15goproto_sizecache_all\x12\x1c.google.protobuf.FileOptions\x18\xba\xec\x03 \x01(\x08:;\n\x13goproto_unkeyed_all\x12\x1c.google.protobuf.FileOptions\x18\xbb\xec\x03 \x01(\x08::\n\x0fgoproto_getters\x12\x1f.google.protobuf.MessageOptions\x18\x81\xf4\x03 \x01(\x08:;\n\x10goproto_stringer\x12\x1f.google.protobuf.MessageOptions\x18\x83\xf4\x03 \x01(\x08:8\n\rverbose_equal\x12\x1f.google.protobuf.MessageOptions\x18\x84\xf4\x03 \x01(\x08:/\n\x04\x66\x61\x63\x65\x12\x1f.google.protobuf.MessageOptions\x18\x85\xf4\x03 \x01(\x08:3\n\x08gostring\x12\x1f.google.protobuf.MessageOptions\x18\x86\xf4\x03 \x01(\x08:3\n\x08populate\x12\x1f.google.protobuf.MessageOptions\x18\x87\xf4\x03 \x01(\x08:3\n\x08stringer\x12\x1f.google.protobuf.MessageOptions\x18\xc0\x8b\x04 \x01(\x08:2\n\x07onlyone\x12\x1f.google.protobuf.MessageOptions\x18\x89\xf4\x03 \x01(\x08:0\n\x05\x65qual\x12\x1f.google.protobuf.MessageOptions\x18\x8d\xf4\x03 \x01(\x08:6\n\x0b\x64\x65scription\x12\x1f.google.protobuf.MessageOptions\x18\x8e\xf4\x03 \x01(\x08:2\n\x07testgen\x12\x1f.google.protobuf.MessageOptions\x18\x8f\xf4\x03 \x01(\x08:3\n\x08\x62\x65nchgen\x12\x1f.google.protobuf.MessageOptions\x18\x90\xf4\x03 \x01(\x08:4\n\tmarshaler\x12\x1f.google.protobuf.MessageOptions\x18\x91\xf4\x03 \x01(\x08:6\n\x0bunmarshaler\x12\x1f.google.protobuf.MessageOptions\x18\x92\xf4\x03 \x01(\x08:;\n\x10stable_marshaler\x12\x1f.google.protobuf.MessageOptions\x18\x93\xf4\x03 \x01(\x08:0\n\x05sizer\x12\x1f.google.protobuf.MessageOptions\x18\x94\xf4\x03 \x01(\x08:;\n\x10unsafe_marshaler\x12\x1f.google.protobuf.MessageOptions\x18\x97\xf4\x03 \x01(\x08:=\n\x12unsafe_unmarshaler\x12\x1f.google.protobuf.MessageOptions\x18\x98\xf4\x03 \x01(\x08:A\n\x16goproto_extensions_map\x12\x1f.google.protobuf.MessageOptions\x18\x99\xf4\x03 \x01(\x08:?\n\x14goproto_unrecognized\x12\x1f.google.protobuf.MessageOptions\x18\x9a\xf4\x03 \x01(\x08:5\n\nprotosizer\x12\x1f.google.protobuf.MessageOptions\x18\x9c\xf4\x03 \x01(\x08:2\n\x07\x63ompare\x12\x1f.google.protobuf.MessageOptions\x18\x9d\xf4\x03 \x01(\x08:3\n\x08typedecl\x12\x1f.google.protobuf.MessageOptions\x18\x9e\xf4\x03 \x01(\x08:6\n\x0bmessagename\x12\x1f.google.protobuf.MessageOptions\x18\xa1\xf4\x03 \x01(\x08:<\n\x11goproto_sizecache\x12\x1f.google.protobuf.MessageOptions\x18\xa2\xf4\x03 \x01(\x08::\n\x0fgoproto_unkeyed\x12\x1f.google.protobuf.MessageOptions\x18\xa3\xf4\x03 \x01(\x08:1\n\x08nullable\x12\x1d.google.protobuf.FieldOptions\x18\xe9\xfb\x03 \x01(\x08:.\n\x05\x65mbed\x12\x1d.google.protobuf.FieldOptions\x18\xea\xfb\x03 \x01(\x08:3\n\ncustomtype\x12\x1d.google.protobuf.FieldOptions\x18\xeb\xfb\x03 \x01(\t:3\n\ncustomname\x12\x1d.google.protobuf.FieldOptions\x18\xec\xfb\x03 \x01(\t:0\n\x07jsontag\x12\x1d.google.protobuf.FieldOptions\x18\xed\xfb\x03 \x01(\t:1\n\x08moretags\x12\x1d.google.protobuf.FieldOptions\x18\xee\xfb\x03 \x01(\t:1\n\x08\x63\x61sttype\x12\x1d.google.protobuf.FieldOptions\x18\xef\xfb\x03 \x01(\t:0\n\x07\x63\x61stkey\x12\x1d.google.protobuf.FieldOptions\x18\xf0\xfb\x03 \x01(\t:2\n\tcastvalue\x12\x1d.google.protobuf.FieldOptions\x18\xf1\xfb\x03 \x01(\t:0\n\x07stdtime\x12\x1d.google.protobuf.FieldOptions\x18\xf2\xfb\x03 \x01(\x08:4\n\x0bstdduration\x12\x1d.google.protobuf.FieldOptions\x18\xf3\xfb\x03 \x01(\x08:3\n\nwktpointer\x12\x1d.google.protobuf.FieldOptions\x18\xf4\xfb\x03 \x01(\x08\x42\x45\n\x13\x63om.google.protobufB\nGoGoProtosZ"github.com/gogo/protobuf/gogoproto' +) + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages( + DESCRIPTOR, + "opentelemetry.exporter.prometheus_remote_write.gen.gogoproto.gogo_pb2", + globals(), +) +if _descriptor._USE_C_DESCRIPTORS == False: + google_dot_protobuf_dot_descriptor__pb2.EnumOptions.RegisterExtension( + goproto_enum_prefix + ) + google_dot_protobuf_dot_descriptor__pb2.EnumOptions.RegisterExtension( + goproto_enum_stringer + ) + google_dot_protobuf_dot_descriptor__pb2.EnumOptions.RegisterExtension( + enum_stringer + ) + google_dot_protobuf_dot_descriptor__pb2.EnumOptions.RegisterExtension( + enum_customname + ) + google_dot_protobuf_dot_descriptor__pb2.EnumOptions.RegisterExtension( + enumdecl + ) + google_dot_protobuf_dot_descriptor__pb2.EnumValueOptions.RegisterExtension( + enumvalue_customname + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + goproto_getters_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + goproto_enum_prefix_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + goproto_stringer_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + verbose_equal_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + face_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + gostring_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + populate_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + stringer_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + onlyone_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + equal_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + description_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + testgen_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + benchgen_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + marshaler_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + unmarshaler_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + stable_marshaler_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + sizer_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + goproto_enum_stringer_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + enum_stringer_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + unsafe_marshaler_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + unsafe_unmarshaler_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + goproto_extensions_map_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + goproto_unrecognized_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + gogoproto_import + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + protosizer_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + compare_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + typedecl_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + enumdecl_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + goproto_registration + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + messagename_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + goproto_sizecache_all + ) + google_dot_protobuf_dot_descriptor__pb2.FileOptions.RegisterExtension( + goproto_unkeyed_all + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + goproto_getters + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + goproto_stringer + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + verbose_equal + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + face + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + gostring + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + populate + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + stringer + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + onlyone + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + equal + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + description + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + testgen + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + benchgen + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + marshaler + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + unmarshaler + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + stable_marshaler + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + sizer + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + unsafe_marshaler + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + unsafe_unmarshaler + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + goproto_extensions_map + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + goproto_unrecognized + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + protosizer + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + compare + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + typedecl + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + messagename + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + goproto_sizecache + ) + google_dot_protobuf_dot_descriptor__pb2.MessageOptions.RegisterExtension( + goproto_unkeyed + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + nullable + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + embed + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + customtype + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + customname + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + jsontag + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + moretags + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + casttype + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + castkey + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + castvalue + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + stdtime + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + stdduration + ) + google_dot_protobuf_dot_descriptor__pb2.FieldOptions.RegisterExtension( + wktpointer + ) + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\023com.google.protobufB\nGoGoProtosZ"github.com/gogo/protobuf/gogoproto' +# @@protoc_insertion_point(module_scope) diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/gen/remote_pb2.py b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/gen/remote_pb2.py new file mode 100644 index 0000000000..09d13a7a09 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/gen/remote_pb2.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: opentelemetry/exporter/prometheus_remote_write/gen/remote.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from opentelemetry.exporter.prometheus_remote_write.gen import ( + types_pb2 as opentelemetry_dot_exporter_dot_prometheus__remote__write_dot_gen_dot_types__pb2, +) +from opentelemetry.exporter.prometheus_remote_write.gen.gogoproto import ( + gogo_pb2 as opentelemetry_dot_exporter_dot_prometheus__remote__write_dot_gen_dot_gogoproto_dot_gogo__pb2, +) + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n?opentelemetry/exporter/prometheus_remote_write/gen/remote.proto\x12\nprometheus\x1a>opentelemetry/exporter/prometheus_remote_write/gen/types.proto\x1aGopentelemetry/exporter/prometheus_remote_write/gen/gogoproto/gogo.proto"z\n\x0cWriteRequest\x12\x30\n\ntimeseries\x18\x01 \x03(\x0b\x32\x16.prometheus.TimeSeriesB\x04\xc8\xde\x1f\x00\x12\x32\n\x08metadata\x18\x03 \x03(\x0b\x32\x1a.prometheus.MetricMetadataB\x04\xc8\xde\x1f\x00J\x04\x08\x02\x10\x03"\xae\x01\n\x0bReadRequest\x12"\n\x07queries\x18\x01 \x03(\x0b\x32\x11.prometheus.Query\x12\x45\n\x17\x61\x63\x63\x65pted_response_types\x18\x02 \x03(\x0e\x32$.prometheus.ReadRequest.ResponseType"4\n\x0cResponseType\x12\x0b\n\x07SAMPLES\x10\x00\x12\x17\n\x13STREAMED_XOR_CHUNKS\x10\x01"8\n\x0cReadResponse\x12(\n\x07results\x18\x01 \x03(\x0b\x32\x17.prometheus.QueryResult"\x8f\x01\n\x05Query\x12\x1a\n\x12start_timestamp_ms\x18\x01 \x01(\x03\x12\x18\n\x10\x65nd_timestamp_ms\x18\x02 \x01(\x03\x12*\n\x08matchers\x18\x03 \x03(\x0b\x32\x18.prometheus.LabelMatcher\x12$\n\x05hints\x18\x04 \x01(\x0b\x32\x15.prometheus.ReadHints"9\n\x0bQueryResult\x12*\n\ntimeseries\x18\x01 \x03(\x0b\x32\x16.prometheus.TimeSeries"]\n\x13\x43hunkedReadResponse\x12\x31\n\x0e\x63hunked_series\x18\x01 \x03(\x0b\x32\x19.prometheus.ChunkedSeries\x12\x13\n\x0bquery_index\x18\x02 \x01(\x03\x42\x08Z\x06prompbb\x06proto3' +) + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages( + DESCRIPTOR, + "opentelemetry.exporter.prometheus_remote_write.gen.remote_pb2", + globals(), +) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b"Z\006prompb" + _WRITEREQUEST.fields_by_name["timeseries"]._options = None + _WRITEREQUEST.fields_by_name[ + "timeseries" + ]._serialized_options = b"\310\336\037\000" + _WRITEREQUEST.fields_by_name["metadata"]._options = None + _WRITEREQUEST.fields_by_name[ + "metadata" + ]._serialized_options = b"\310\336\037\000" + _WRITEREQUEST._serialized_start = 216 + _WRITEREQUEST._serialized_end = 338 + _READREQUEST._serialized_start = 341 + _READREQUEST._serialized_end = 515 + _READREQUEST_RESPONSETYPE._serialized_start = 463 + _READREQUEST_RESPONSETYPE._serialized_end = 515 + _READRESPONSE._serialized_start = 517 + _READRESPONSE._serialized_end = 573 + _QUERY._serialized_start = 576 + _QUERY._serialized_end = 719 + _QUERYRESULT._serialized_start = 721 + _QUERYRESULT._serialized_end = 778 + _CHUNKEDREADRESPONSE._serialized_start = 780 + _CHUNKEDREADRESPONSE._serialized_end = 873 +# @@protoc_insertion_point(module_scope) diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/gen/types_pb2.py b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/gen/types_pb2.py new file mode 100644 index 0000000000..a58e0194ee --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/gen/types_pb2.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: opentelemetry/exporter/prometheus_remote_write/gen/types.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database + +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from opentelemetry.exporter.prometheus_remote_write.gen.gogoproto import ( + gogo_pb2 as opentelemetry_dot_exporter_dot_prometheus__remote__write_dot_gen_dot_gogoproto_dot_gogo__pb2, +) + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n>opentelemetry/exporter/prometheus_remote_write/gen/types.proto\x12\nprometheus\x1aGopentelemetry/exporter/prometheus_remote_write/gen/gogoproto/gogo.proto"\xf8\x01\n\x0eMetricMetadata\x12\x33\n\x04type\x18\x01 \x01(\x0e\x32%.prometheus.MetricMetadata.MetricType\x12\x1a\n\x12metric_family_name\x18\x02 \x01(\t\x12\x0c\n\x04help\x18\x04 \x01(\t\x12\x0c\n\x04unit\x18\x05 \x01(\t"y\n\nMetricType\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x0b\n\x07\x43OUNTER\x10\x01\x12\t\n\x05GAUGE\x10\x02\x12\r\n\tHISTOGRAM\x10\x03\x12\x12\n\x0eGAUGEHISTOGRAM\x10\x04\x12\x0b\n\x07SUMMARY\x10\x05\x12\x08\n\x04INFO\x10\x06\x12\x0c\n\x08STATESET\x10\x07"*\n\x06Sample\x12\r\n\x05value\x18\x01 \x01(\x01\x12\x11\n\ttimestamp\x18\x02 \x01(\x03"U\n\x08\x45xemplar\x12\'\n\x06labels\x18\x01 \x03(\x0b\x32\x11.prometheus.LabelB\x04\xc8\xde\x1f\x00\x12\r\n\x05value\x18\x02 \x01(\x01\x12\x11\n\ttimestamp\x18\x03 \x01(\x03"\x8f\x01\n\nTimeSeries\x12\'\n\x06labels\x18\x01 \x03(\x0b\x32\x11.prometheus.LabelB\x04\xc8\xde\x1f\x00\x12)\n\x07samples\x18\x02 \x03(\x0b\x32\x12.prometheus.SampleB\x04\xc8\xde\x1f\x00\x12-\n\texemplars\x18\x03 \x03(\x0b\x32\x14.prometheus.ExemplarB\x04\xc8\xde\x1f\x00"$\n\x05Label\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t"1\n\x06Labels\x12\'\n\x06labels\x18\x01 \x03(\x0b\x32\x11.prometheus.LabelB\x04\xc8\xde\x1f\x00"\x82\x01\n\x0cLabelMatcher\x12+\n\x04type\x18\x01 \x01(\x0e\x32\x1d.prometheus.LabelMatcher.Type\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\r\n\x05value\x18\x03 \x01(\t"(\n\x04Type\x12\x06\n\x02\x45Q\x10\x00\x12\x07\n\x03NEQ\x10\x01\x12\x06\n\x02RE\x10\x02\x12\x07\n\x03NRE\x10\x03"|\n\tReadHints\x12\x0f\n\x07step_ms\x18\x01 \x01(\x03\x12\x0c\n\x04\x66unc\x18\x02 \x01(\t\x12\x10\n\x08start_ms\x18\x03 \x01(\x03\x12\x0e\n\x06\x65nd_ms\x18\x04 \x01(\x03\x12\x10\n\x08grouping\x18\x05 \x03(\t\x12\n\n\x02\x62y\x18\x06 \x01(\x08\x12\x10\n\x08range_ms\x18\x07 \x01(\x03"\x8b\x01\n\x05\x43hunk\x12\x13\n\x0bmin_time_ms\x18\x01 \x01(\x03\x12\x13\n\x0bmax_time_ms\x18\x02 \x01(\x03\x12(\n\x04type\x18\x03 \x01(\x0e\x32\x1a.prometheus.Chunk.Encoding\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c" \n\x08\x45ncoding\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x07\n\x03XOR\x10\x01"a\n\rChunkedSeries\x12\'\n\x06labels\x18\x01 \x03(\x0b\x32\x11.prometheus.LabelB\x04\xc8\xde\x1f\x00\x12\'\n\x06\x63hunks\x18\x02 \x03(\x0b\x32\x11.prometheus.ChunkB\x04\xc8\xde\x1f\x00\x42\x08Z\x06prompbb\x06proto3' +) + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages( + DESCRIPTOR, + "opentelemetry.exporter.prometheus_remote_write.gen.types_pb2", + globals(), +) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b"Z\006prompb" + _EXEMPLAR.fields_by_name["labels"]._options = None + _EXEMPLAR.fields_by_name[ + "labels" + ]._serialized_options = b"\310\336\037\000" + _TIMESERIES.fields_by_name["labels"]._options = None + _TIMESERIES.fields_by_name[ + "labels" + ]._serialized_options = b"\310\336\037\000" + _TIMESERIES.fields_by_name["samples"]._options = None + _TIMESERIES.fields_by_name[ + "samples" + ]._serialized_options = b"\310\336\037\000" + _TIMESERIES.fields_by_name["exemplars"]._options = None + _TIMESERIES.fields_by_name[ + "exemplars" + ]._serialized_options = b"\310\336\037\000" + _LABELS.fields_by_name["labels"]._options = None + _LABELS.fields_by_name["labels"]._serialized_options = b"\310\336\037\000" + _CHUNKEDSERIES.fields_by_name["labels"]._options = None + _CHUNKEDSERIES.fields_by_name[ + "labels" + ]._serialized_options = b"\310\336\037\000" + _CHUNKEDSERIES.fields_by_name["chunks"]._options = None + _CHUNKEDSERIES.fields_by_name[ + "chunks" + ]._serialized_options = b"\310\336\037\000" + _METRICMETADATA._serialized_start = 152 + _METRICMETADATA._serialized_end = 400 + _METRICMETADATA_METRICTYPE._serialized_start = 279 + _METRICMETADATA_METRICTYPE._serialized_end = 400 + _SAMPLE._serialized_start = 402 + _SAMPLE._serialized_end = 444 + _EXEMPLAR._serialized_start = 446 + _EXEMPLAR._serialized_end = 531 + _TIMESERIES._serialized_start = 534 + _TIMESERIES._serialized_end = 677 + _LABEL._serialized_start = 679 + _LABEL._serialized_end = 715 + _LABELS._serialized_start = 717 + _LABELS._serialized_end = 766 + _LABELMATCHER._serialized_start = 769 + _LABELMATCHER._serialized_end = 899 + _LABELMATCHER_TYPE._serialized_start = 859 + _LABELMATCHER_TYPE._serialized_end = 899 + _READHINTS._serialized_start = 901 + _READHINTS._serialized_end = 1025 + _CHUNK._serialized_start = 1028 + _CHUNK._serialized_end = 1167 + _CHUNK_ENCODING._serialized_start = 1135 + _CHUNK_ENCODING._serialized_end = 1167 + _CHUNKEDSERIES._serialized_start = 1169 + _CHUNKEDSERIES._serialized_end = 1266 +# @@protoc_insertion_point(module_scope) diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/version.py b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/version.py new file mode 100644 index 0000000000..09b3473b7d --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/src/opentelemetry/exporter/prometheus_remote_write/version.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.34b0" diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/tests/__init__.py b/exporter/opentelemetry-exporter-prometheus-remote-write/tests/__init__.py new file mode 100644 index 0000000000..b0a6f42841 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/tests/conftest.py b/exporter/opentelemetry-exporter-prometheus-remote-write/tests/conftest.py new file mode 100644 index 0000000000..259de7b7a2 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/tests/conftest.py @@ -0,0 +1,66 @@ +import random + +import pytest + +import opentelemetry.test.metrictestutil as metric_util +from opentelemetry.exporter.prometheus_remote_write import ( + PrometheusRemoteWriteMetricsExporter, +) +from opentelemetry.sdk.metrics.export import ( + AggregationTemporality, + Histogram, + HistogramDataPoint, + Metric, +) + + +@pytest.fixture +def prom_rw(): + return PrometheusRemoteWriteMetricsExporter( + "http://victoria:8428/api/v1/write" + ) + + +@pytest.fixture +def metric(request): + if hasattr(request, "param"): + type_ = request.param + else: + type_ = random.choice(["gauge", "sum"]) + + if type_ == "gauge": + return metric_util._generate_gauge( + "test.gauge", random.randint(0, 100) + ) + if type_ == "sum": + return metric_util._generate_sum( + "test.sum", random.randint(0, 9_999_999_999) + ) + if type_ == "histogram": + return _generate_histogram("test_histogram") + + raise ValueError(f"Unsupported metric type '{type_}'.") + + +def _generate_histogram(name): + dp = HistogramDataPoint( + attributes={"foo": "bar", "baz": 42}, + start_time_unix_nano=1641946016139533244, + time_unix_nano=1641946016139533244, + count=5, + sum=420, + bucket_counts=[1, 4], + explicit_bounds=[10.0], + min=8, + max=80, + ) + data = Histogram( + [dp], + AggregationTemporality.CUMULATIVE, + ) + return Metric( + name, + "foo", + "tu", + data=data, + ) diff --git a/exporter/opentelemetry-exporter-prometheus-remote-write/tests/test_prometheus_remote_write_exporter.py b/exporter/opentelemetry-exporter-prometheus-remote-write/tests/test_prometheus_remote_write_exporter.py new file mode 100644 index 0000000000..4579baad68 --- /dev/null +++ b/exporter/opentelemetry-exporter-prometheus-remote-write/tests/test_prometheus_remote_write_exporter.py @@ -0,0 +1,309 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from unittest.mock import patch + +import pytest + +from opentelemetry.exporter.prometheus_remote_write import ( + PrometheusRemoteWriteMetricsExporter, +) +from opentelemetry.exporter.prometheus_remote_write.gen.types_pb2 import ( # pylint: disable=E0611 + TimeSeries, +) +from opentelemetry.sdk.metrics.export import ( + Histogram, + HistogramDataPoint, + MetricExportResult, + MetricsData, + NumberDataPoint, + ResourceMetrics, + ScopeMetrics, +) +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.util.instrumentation import InstrumentationScope + + +@pytest.mark.parametrize( + "name,result", + [ + ("abc.124", "abc_124"), + (":abc", ":abc"), + ("abc.name.hi", "abc_name_hi"), + ("service.name...", "service_name___"), + ("4hellowor:ld5∂©∑", "_hellowor:ld5___"), + ], +) +def test_regex(name, result, prom_rw): + assert prom_rw._sanitize_string(name, "name") == result + + +def test_regex_invalid(prom_rw): + with pytest.raises(TypeError): + prom_rw("foo_bar", "A random type") + + +def test_parse_data_point(prom_rw): + + attrs = {"Foo": "Bar", "Baz": 42} + timestamp = 1641946016139533244 + value = 242.42 + dp = NumberDataPoint(attrs, 0, timestamp, value) + name = "abc.123_42" + labels, sample = prom_rw._parse_data_point(dp, name) + + name = "abc_123_42" + assert labels == (("Foo", "Bar"), ("Baz", 42), ("__name__", name)) + assert sample == (value, timestamp // 1_000_000) + + +def test_parse_histogram_dp(prom_rw): + attrs = {"foo": "bar", "baz": 42} + timestamp = 1641946016139533244 + bounds = [10.0, 20.0] + dp = HistogramDataPoint( + attributes=attrs, + start_time_unix_nano=1641946016139533244, + time_unix_nano=timestamp, + count=9, + sum=180, + bucket_counts=[1, 4, 4], + explicit_bounds=bounds, + min=8, + max=80, + ) + name = "foo_histogram" + label_sample_pairs = prom_rw._parse_histogram_data_point(dp, name) + timestamp = timestamp // 1_000_000 + bounds.append("+Inf") + for pos, bound in enumerate(bounds): + # We have to attributes, we kinda assume the bucket label is last... + assert ("le", str(bound)) == label_sample_pairs[pos][0][-1] + # Check and make sure we are putting the bucket counts in there + assert (dp.bucket_counts[pos], timestamp) == label_sample_pairs[pos][1] + + # Last two are the sum & total count + assert ("__name__", f"{name}_sum") in label_sample_pairs[-2][0] + assert (dp.sum, timestamp) == label_sample_pairs[-2][1] + + assert ("__name__", f"{name}_count") in label_sample_pairs[-1][0] + assert (dp.count, timestamp) == label_sample_pairs[-1][1] + + +@pytest.mark.parametrize( + "metric", + [ + "gauge", + "sum", + "histogram", + ], + indirect=["metric"], +) +def test_parse_metric(metric, prom_rw): + """ + Ensures output from parse_metrics are TimeSeries with expected data/size + """ + attributes = { + "service_name": "foo", + "bool_value": True, + } + + assert ( + len(metric.data.data_points) == 1 + ), "We can only support a single datapoint in tests" + series = prom_rw._parse_metric(metric, tuple(attributes.items())) + timestamp = metric.data.data_points[0].time_unix_nano // 1_000_000 + for single_series in series: + labels = str(single_series.labels) + # Its a bit easier to validate these stringified where we dont have to + # worry about ordering and protobuf TimeSeries object structure + # This doesn't guarantee the labels aren't mixed up, but our other + # test cases already do. + assert "__name__" in labels + assert prom_rw._sanitize_string(metric.name, "name") in labels + combined_attrs = list(attributes.items()) + list( + metric.data.data_points[0].attributes.items() + ) + for name, value in combined_attrs: + assert prom_rw._sanitize_string(name, "label") in labels + assert str(value) in labels + if isinstance(metric.data, Histogram): + values = [ + metric.data.data_points[0].count, + metric.data.data_points[0].sum, + metric.data.data_points[0].bucket_counts[0], + metric.data.data_points[0].bucket_counts[1], + ] + else: + values = [ + metric.data.data_points[0].value, + ] + for sample in single_series.samples: + assert sample.timestamp == timestamp + assert sample.value in values + + +class TestValidation(unittest.TestCase): + # Test cases to ensure exporter parameter validation works as intended + def test_valid_standard_param(self): + exporter = PrometheusRemoteWriteMetricsExporter( + endpoint="/prom/test_endpoint", + ) + self.assertEqual(exporter.endpoint, "/prom/test_endpoint") + + def test_valid_basic_auth_param(self): + exporter = PrometheusRemoteWriteMetricsExporter( + endpoint="/prom/test_endpoint", + basic_auth={ + "username": "test_username", + "password": "test_password", + }, + ) + self.assertEqual(exporter.basic_auth["username"], "test_username") + self.assertEqual(exporter.basic_auth["password"], "test_password") + + def test_invalid_no_endpoint_param(self): + with self.assertRaises(ValueError): + PrometheusRemoteWriteMetricsExporter("") + + def test_invalid_no_username_param(self): + with self.assertRaises(ValueError): + PrometheusRemoteWriteMetricsExporter( + endpoint="/prom/test_endpoint", + basic_auth={"password": "test_password"}, + ) + + def test_invalid_no_password_param(self): + with self.assertRaises(ValueError): + PrometheusRemoteWriteMetricsExporter( + endpoint="/prom/test_endpoint", + basic_auth={"username": "test_username"}, + ) + + def test_invalid_conflicting_passwords_param(self): + with self.assertRaises(ValueError): + PrometheusRemoteWriteMetricsExporter( + endpoint="/prom/test_endpoint", + basic_auth={ + "username": "test_username", + "password": "test_password", + "password_file": "test_file", + }, + ) + + def test_invalid_timeout_param(self): + with self.assertRaises(ValueError): + PrometheusRemoteWriteMetricsExporter( + endpoint="/prom/test_endpoint", timeout=0 + ) + + def test_valid_tls_config_param(self): + tls_config = { + "ca_file": "test_ca_file", + "cert_file": "test_cert_file", + "key_file": "test_key_file", + "insecure_skip_verify": True, + } + exporter = PrometheusRemoteWriteMetricsExporter( + endpoint="/prom/test_endpoint", tls_config=tls_config + ) + self.assertEqual(exporter.tls_config["ca_file"], tls_config["ca_file"]) + self.assertEqual( + exporter.tls_config["cert_file"], tls_config["cert_file"] + ) + self.assertEqual( + exporter.tls_config["key_file"], tls_config["key_file"] + ) + self.assertEqual( + exporter.tls_config["insecure_skip_verify"], + tls_config["insecure_skip_verify"], + ) + + # if cert_file is provided, then key_file must also be provided + def test_invalid_tls_config_cert_only_param(self): + tls_config = {"cert_file": "value"} + with self.assertRaises(ValueError): + PrometheusRemoteWriteMetricsExporter( + endpoint="/prom/test_endpoint", tls_config=tls_config + ) + + # if cert_file is provided, then key_file must also be provided + def test_invalid_tls_config_key_only_param(self): + tls_config = {"cert_file": "value"} + with self.assertRaises(ValueError): + PrometheusRemoteWriteMetricsExporter( + endpoint="/prom/test_endpoint", tls_config=tls_config + ) + + +# Ensures export is successful with valid export_records and config +@patch("requests.post") +def test_valid_export(mock_post, prom_rw, metric): + mock_post.return_value.configure_mock(**{"status_code": 200}) + + # Assumed a "None" for Scope or Resource aren't valid, so build them here + scope = ScopeMetrics( + InstrumentationScope(name="prom-rw-test"), [metric], None + ) + resource = ResourceMetrics( + Resource({"service.name": "foo"}), [scope], None + ) + record = MetricsData([resource]) + + result = prom_rw.export(record) + assert result == MetricExportResult.SUCCESS + assert mock_post.call_count == 1 + + result = prom_rw.export([]) + assert result == MetricExportResult.SUCCESS + + +def test_invalid_export(prom_rw): + record = MetricsData([]) + + result = prom_rw.export(record) + assert result == MetricExportResult.FAILURE + + +@patch("requests.post") +def test_valid_send_message(mock_post, prom_rw): + mock_post.return_value.configure_mock(**{"ok": True}) + result = prom_rw._send_message(bytes(), {}) + assert mock_post.call_count == 1 + assert result == MetricExportResult.SUCCESS + + +def test_invalid_send_message(prom_rw): + result = prom_rw._send_message(bytes(), {}) + assert result == MetricExportResult.FAILURE + + +# Verifies that build_message calls snappy.compress and returns SerializedString +@patch("snappy.compress", return_value=bytes()) +def test_build_message(mock_compress, prom_rw): + message = prom_rw._build_message([TimeSeries()]) + assert mock_compress.call_count == 1 + assert isinstance(message, bytes) + + +# Ensure correct headers are added when valid config is provided +def test_build_headers(prom_rw): + prom_rw.headers = {"Custom Header": "test_header"} + + headers = prom_rw._build_headers() + assert headers["Content-Encoding"] == "snappy" + assert headers["Content-Type"] == "application/x-protobuf" + assert headers["X-Prometheus-Remote-Write-Version"] == "0.1.0" + assert headers["Custom Header"] == "test_header" diff --git a/tox.ini b/tox.ini index 4c028e2ccc..081b721634 100644 --- a/tox.ini +++ b/tox.ini @@ -104,6 +104,9 @@ envlist = ; opentelemetry-exporter-richconsole py3{7,8,9,10}-test-exporter-richconsole + ; opentelemetry-exporter-prometheus-remote-write + py3{6,7,8,9,10}-test-exporter-prometheus-remote-write + ; opentelemetry-instrumentation-mysql py3{7,8,9,10}-test-instrumentation-mysql pypy3-test-instrumentation-mysql @@ -300,6 +303,7 @@ changedir = test-propagator-aws: propagator/opentelemetry-propagator-aws-xray/tests test-propagator-ot-trace: propagator/opentelemetry-propagator-ot-trace/tests test-exporter-richconsole: exporter/opentelemetry-exporter-richconsole/tests + test-exporter-prometheus-remote-write: exporter/opentelemetry-exporter-prometheus-remote-write/tests commands_pre = ; Install without -e to test the actual installation @@ -387,6 +391,8 @@ commands_pre = richconsole: pip install flaky {toxinidir}/exporter/opentelemetry-exporter-richconsole[test] + prometheus: pip install {toxinidir}/exporter/opentelemetry-exporter-prometheus-remote-write[test] + sklearn: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-sklearn[test] sqlalchemy{11,14}: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-sqlalchemy[test] @@ -498,6 +504,7 @@ commands_pre = python -m pip install -e {toxinidir}/instrumentation/opentelemetry-instrumentation-aws-lambda[test] python -m pip install -e {toxinidir}/instrumentation/opentelemetry-instrumentation-system-metrics[test] python -m pip install -e {toxinidir}/exporter/opentelemetry-exporter-richconsole[test] + python -m pip install -e {toxinidir}/exporter/opentelemetry-exporter-prometheus-remote-write[test] python -m pip install -e {toxinidir}/sdk-extension/opentelemetry-sdk-extension-aws[test] python -m pip install -e {toxinidir}/propagator/opentelemetry-propagator-aws-xray[test] python -m pip install -e {toxinidir}/propagator/opentelemetry-propagator-ot-trace[test]