Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Cortex and Prometheus Remote Write exporter design #1464

Merged
merged 7 commits into from
Aug 11, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
287 changes: 287 additions & 0 deletions exporter/cortexexporter/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@

# **OpenTelemetry Collector Cortex Exporter Design**
huyan0 marked this conversation as resolved.
Show resolved Hide resolved

Authors: @huyan0, @danielbang907

Date: July 30, 2020

## **1. Introduction**
huyan0 marked this conversation as resolved.
Show resolved Hide resolved

Cortex is an open source, horizontally scalable, highly available, multi-tenant, long term storage for Prometheus. Cortex accepts data defined by the Prometheus Remote Write API. Our project is focused on developing an exporter for the OpenTelemetry Collector to a Cortex instance.

The following diagram is the architecture of Cortex.

![Cortex Archietecture](https://raw.githubusercontent.com/open-o11y/opentelemetry-collector/design-doc/exporter/cortexexporter/png%3Bbase64e4e5466cd84608b5%20(1).png)

### **1.1 Remote Write API**

Cortex has a Remote Write API that accepts incoming metrics. This exporter should write metrics to a remote URL in a snappy-compressed, protocol buffer encoded HTTP request defined by the Remote Write API. Each request encodes multiple Cortex TimeSeries, which are composed of a set of labels and a collection of samples. Each label contains a name-value pair of strings, and each sample contains a timestamp-value number pair.
huyan0 marked this conversation as resolved.
Show resolved Hide resolved
huyan0 marked this conversation as resolved.
Show resolved Hide resolved

![Image of TimeSeries
huyan0 marked this conversation as resolved.
Show resolved Hide resolved
](https://raw.githubusercontent.com/open-o11y/opentelemetry-collector/design-doc/exporter/cortexexporter/png%3Bbase6470b5ba4b11b18d5c.png)

TimeSeries stores its metric name in its labels and does not describe metric types or start timestamps. To convert to TimeSeries data, buckets of a Histogram are broken down into individual TimeSeries with a bound label(`le`), and a similar process happens with quantiles in a Summary.


More details of Remote Write API can be found in Prometheus [documentation](https://cortexmetrics.io/docs/apis/) and Cortex [documentation](https://cortexmetrics.io/docs/apis/).
huyan0 marked this conversation as resolved.
Show resolved Hide resolved

### **1.2 Gaps and Assumptions**

**Gap 1:**
Currently, metrics from the OpenTelemetry SDKs cannot be exported to Prometheus from the collector correctly ([#1255](https:/open-telemetry/opentelemetry-collector/issues/1255)). This is because the SDKs send metrics to the collector via their OTLP exporter, which exports the delta value of cumulative counters. The same issue will arise for any cumulative backend service, such as Cortex.

To overcome this gap in the Collector pipeline, we had proposed 2 different solutions:

1. Add a [metric aggregation processor](https:/open-telemetry/opentelemetry-collector/issues/1422) to the collector pipeline to aggregate delta values into cumulative values for cumulative backends like Cortex. This solution requires users to set up a collector agent next to each SDK to make sure delta values are aggregated correctly.
2. Require the OTLP exporters in SDKs to [send cumulative values for cumulative metric types to the Collector by default](https:/open-telemetry/opentelemetry-specification/issues/731). Therefore, no aggregation of delta metric values is required in the Collector pipeline for Cortex/Prometheus to properly process the data.
huyan0 marked this conversation as resolved.
Show resolved Hide resolved

**Gap 2:**
Another gap is that OTLP metric definition is still in development. This exporter will require refactoring as OTLP changes in the future.
huyan0 marked this conversation as resolved.
Show resolved Hide resolved

**Assumptions:**
Because of the gaps mentioned above, this project will convert from the current OTLP metrics and work under the assumption one of the above solutions will be implemented, and all incoming monotonic scalars/histogram/summary metrics should be cumulative or otherwise dropped. More details on the behavior of the exporter is in section 2.2.

## **2. Cortex Exporter**
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this from Cortex exporter to "Prometheus Remote Write exporter"? Cortex just uses the Prom RW APIs and by renaming we'll be clearer in intent that we support Prom RW and not just Cortex.

Copy link
Member Author

@huyan0 huyan0 Jul 31, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we do want to emphasize the support for Cortex, but we definitely can indicate its compatibility with other remote write backends in our document. Thanks for the suggestion. Also, a question on remote write: does it send out sorted label set and how do we treat duplicate labels?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We send a list of labels. And I think its upto the upstream implementation to deal with the discrepancy. In general, in Prometheus, you can never have a label name that has 2 values, so the upstream will likely reject it with a 4xx. I am fairly certain that the labels need not be sorted.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the reply. Another related question: for Cortex to work, should our exporter meet the requirement that metric of the same type and name must have the same labels? If so, how could we maintain it in the case of multiple exporters?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OTel gives some specification about duplicate labels. They should be eliminated before the exporter sees them. I'm not sure if the SDK specification will say they're sorted, but in the OTel Go SDK they are sorted as an efficient means of deduplication.


The Cortex exporter should receive OTLP metrics, group data points by metric name and label set, convert each group to a TimeSeries, and send all TimeSeries to Cortex via HTTP.

### **2.1 Receiving Metrics**
The Cortex exporter receives a MetricsData instance in its PushMetrics() function. MetricsData contains a collection of Metric instances. Each Metric instance contains a series of data points, and each data point has a set of labels associated with it. Since Cortex TimeSeries are identified by unique sets of labels, the exporter needs to group data points within each Metric instance by their label set, and convert each group to a TimeSeries.

To group data points by label set, the Cortex exporter should create a map with each PushMetrics() call. The key of the map should represent a combination of the following information:

* the metric type
* the metric name
* the set of labels that identify a unique TimeSeries


The exporter should create a signature string as map key by concatenating metric type, metric name, and label names and label values at each data point. To ensure correctness, the label set at each data point should be sorted by label key before generating the signature string.

An alternative key type is in the exiting label.Set implementation from the OpenTelemetry Go API. It provides a Distinct type that guarantees the result will equal the equivalent Distinct value of any label set with the same elements as this,
where sets are made unique by choosing the last value in the input for any given key. If we allocate a Go API's kv.KeyValue for every label of a data point, then a label.Set from the API can be created, and its Distinct value can be used as map key.


The value of the map should be Cortex TimeSeries, and each data point’s value and timestamp should be inserted to its corresponding TimeSeries in the map as a Sample, each metric’s label set and metric name should be combined and translated to a Cortex label set; a new TimeSeries should be created if the string signature is not in the map.


Pseudocode:

func PushMetrics(metricsData) {

// Create a map that stores distinct TimeSeries
map := make(map[String][]TimeSeries)

for metric in metricsData:
for point in metric:
// Generate signature string
sig := pointSignature(metric, point)

// Find corresponding TimeSeries in map
// Add to TimeSeries

// Sends TimeSeries to backend
export(map)
}

### **2.2 Mapping of OTLP Metrics to TimeSeries**

Each Cortex TimeSeries represents less semantic information than an OTLP metric. The temporality property of a OTLP metric is ignored in a TimeSeries because it is always considered as cumulative for monotonic types and histogram, and the type property of a OTLP metric is translated by mapping each metric to one or multiple TimeSeries. The following sections explain how to map each OTLP metric type to Cortex TimeSeries.


**INT64, MONOTONIC_INT64, DOUBLE, MONOTONIC_DOUBLE**

Each unique label set within metrics of these types can be converted to exactly one TimeSeries. From the perspective of Prometheus Client types, INT64 and DOUBLE correspond to gauge metrics, and MONOTONIC types correspond to counter metrics. In both cases, data points will be exported directly without aggregation. Any metric of the monotonic types that is not cumulative should be dropped; non-monotonic scalar types are assumed to represent gauge values, thus its temporality is not checked. Monotonic types need to have a `_total` suffix in its metric name when exporting; this is a requirement of [Prometheus](https://www.slideshare.net/brianbrazil/openmetrics-what-does-it-mean-for-you-promcon-2019-munich) (slide 11).


**HISTOGRAM**

Each histogram data point can be converted to 2 + n + 1 Cortex TimeSeries:

* 1 *TimeSeries* representing metric_name_count contains HistogramDataPoint.count
* 1 *TimeSeries* representing metric_name_sum contains HistogramDataPoint.sum
* n *TimeSeries* each representing metric_name_bucket{le=“upperbound”} contain the count of each bucket defined by the bounds of the data point
* 1 *TimeSeries* representing metric_name_bucket{le=“+Inf”} contains counts for the bucket with infinity as upper bound; its value is equivalent to metric_name_count.

Cortex bucket values are cumulative, meaning the count of each bucket should contain counts from buckets with lower bounds. In addition, Exemplars from a histogram data point are ignored. When adding a bucket of the histogram data point to the map, the string signature should also contain a `le` label that indicates the bound value. This label should also be exported. Any histogram metric that is not cumulative should be dropped.


**SUMMARY**

Each summary data point can be converted to 2 + n Cortex TimeSeries:

* 1 *TimeSeries* representing metric_name_count contains SummaryDataPoint.count
* 1 *TimeSeries* representing metric_name_sum contains SummaryDataPoint.sum
* and n *TimeSeries* each representing metric_name{quantile=“quantileValue”} contains the value of each quantile in the data point.

When adding a quantile of the summary data point to the map, the string signature should also contain a `quantile ` label that indicates the quantile value. This label should also be exported. Any summary metric that is not cumulative should be dropped.

### **2.3 Exporting Metrics**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be easier to vendor in Prometheus remote write package here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion, using the client from remote write makes sense. We are also trying to allow users to specify any header or pass in a http.Client to send requests. I will take a look at whether this is possible with remote write package.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't sadly, but we should see if we can make the current client accept a custom http.Client.

https:/prometheus/prometheus/blob/master/storage/remote/client.go#L125-L133

We can try adding a SetHTTPClient() method to the Prometheus client object.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think that change would be very helpful for our usage.


The Cortex exporter should call proto.Marshal() to convert multiple TimeSeries to a byte array. Then, the exporter should send the byte array to Cortex in a HTTP request.


Authentication credentials should be added to each request before sending to the backend. Basic auth and bearer token headers can be added using Golang http.Client’s default configuration options. Other authentication headers can be added by implementing a client interceptor.


Pseudocode:


func export(*map) error {
// Stores timeseries
arr := make([]TimeSeries)

for timeseries in map:
arr = append(arr, timeseries)

// Converts arr to WriteRequest
request := proto.Marshal(arr)

// Sends HTTP request to endpoint
}

## **3. Other Components**

### **3.1 Config Struct**

This struct is based on an inputted YAML file at the beginning of the pipeline and defines the configurations for an Exporter build. Examples of configuration parameters are HTTP endpoint, compression type, backend program, etc.


Converting YAML to a Go struct is done by the Collector, using [the Viper package](https:/spf13/viper), which is an open-source library that seamlessly converts inputted YAML files into a usable, appropriate Config struct.


An example of the exporter section of the Collector config.yml YAML file can be seen below:

...

exporters:
cortex:
huyan0 marked this conversation as resolved.
Show resolved Hide resolved
http_endpoint: <string>
# Prefix to metric name
namespace: <string>
# Labels to add to each TimeSeries
const_labels:
[label: <string>]
# Allow users to add any header; only required headers listed here
headers:
[X-Prometheus-Remote-Write-Version:<string>]
[Tenant-id:<int>]
request_timeout: <int>

# ************************************************************************
# below are configurations copied from Prometheus remote write config
# ************************************************************************
# Sets the `Authorization` header on every remote write request with the
# configured username and password.
# password and password_file are mutually exclusive.
basic_auth:
[ username: <string> ]
[ password: <string> ]
[ password_file: <string> ]

# Sets the `Authorization` header on every remote write request with
# the configured bearer token. It is mutually exclusive with `bearer_token_file`.
[ bearer_token: <string> ]

# Sets the `Authorization` header on every remote write request with the bearer token
# read from the configured file. It is mutually exclusive with `bearer_token`.
[ bearer_token_file: /path/to/bearer/token/file ]

# Configures the remote write request's TLS settings.
tls_config:
# CA certificate to validate API server certificate with.
[ ca_file: <filename> ]

# Certificate and key files for client cert authentication to the server.
[ cert_file: <filename> ]
[ key_file: <filename> ]

# ServerName extension to indicate the name of the server.
# https://tools.ietf.org/html/rfc4366#section-3.1
[ server_name: <string> ]

# Disable validation of the server certificate.
[ insecure_skip_verify: <boolean> ]

...

### **3.2 Factory Struct**

This struct implements the ExporterFactory interface, and is used during collector’s pipeline initialization to create the Exporter instances as defined by the Config struct. The `exporterhelper` package will be used to create the exporter and the factory.


Our Factory type will look very similar to other exporters’ factory implementation. For our implementation, our Factory instance will implement three methods


**Methods**

NewFactory
This method will use the NewFactory method within the `exporterhelper` package to create a instance of the factory.

createDefaultConfig

This method creates the default configuration for Cortex exporter.


createMetricsExporter

This method constructs a new http.Client with interceptors that add headers to any request it sends. Then, this method initializes a new Cortex exporter with the http.Client. This method constructs a collector Cortex exporter with the created SDK exporter



## **4. Other Considerations**

### **4.1 Concurrency**

The Cortex exporter should be thread-safe; In this design, the only resource shared across goroutines is the http.Client from the Golang library. It is thread-safe, thus, our code is thread-safe.

### **4.2 Shutdown Behavior**

Once the shutdown() function is called, the exporter should stop accepting incoming calls(return error), and wait for current operations to finish before returning. This can be done by using a stop channel and a wait group.

func Shutdown () {
close(stopChan)
waitGroup.Wait()
}

func PushMetrics() {
select:
case <- stopCh
return error
default:
waitGroup.Add(1)
defer waitGroup.Done()
// export metrics
...
}

### **4.3 Timeout Behavior**

Users should be able to pass in a time for the each http request as part of the Configuration. The factory should read the configuration file and set the timeout field of the http.Client

func (f *Factory) CreateNewExporter (config) {
...
client := &http.Client{
Timeout config.requestTimeout
}
...
}

### **4.4 Error Behavior**

The PushMetricsData() function should return the number of dropped metrics. Any MONOTONIC and HISTOGRAM metrics that are not cumulative should be dropped. This can be done by checking the temporality of each received metric. Any error should be returned to the caller, and the error message should be descriptive.



### **4.5 Test Strategy**

We will follow test-driven development practices while completing this project. We’ll write unit tests before implementing production code. Tests will cover normal and abnormal inputs and test for edge cases. We will provide end-to-end tests using mock backend/client. Our target is to get 90% or more of code coverage.



## **Request for Feedback**
We'd like to get some feedback on whether we made the appropriate assumptions in [this](#12-gaps-and-assumptions) section, and appreciate more comments, updates, and suggestions on the topic.

Please let us know if there are any revisions, technical or informational, necessary for this document. Thank you!



Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.