This is an automated email from the ASF dual-hosted git repository. HTHou pushed a commit to branch codex/standalone-metric-scrape in repository https://gitbox.apache.org/repos/asf/iotdb-extras.git
commit 81192b1928571d422b9420ea34b90e2ea5a3e771 Author: HTHou <[email protected]> AuthorDate: Thu Jun 18 17:51:44 2026 +0800 Add metric scrape module --- README-zh.md | 7 +- README.md | 7 +- metric-scrape/Dockerfile | 26 ++ metric-scrape/README.md | 146 ++++++++ metric-scrape/conf/metric-scrape.yml | 61 ++++ metric-scrape/k8s/configmap.yaml | 60 ++++ metric-scrape/k8s/deployment.yaml | 64 ++++ metric-scrape/pom.xml | 112 ++++++ .../apache/iotdb/metricscrape/IdentifierUtils.java | 50 +++ .../iotdb/metricscrape/MetricScrapeConfig.java | 195 +++++++++++ .../metricscrape/MetricScrapeConfigLoader.java | 256 ++++++++++++++ .../iotdb/metricscrape/MetricScrapeHttpClient.java | 59 ++++ .../iotdb/metricscrape/MetricScrapeMain.java | 63 ++++ .../iotdb/metricscrape/MetricScrapePlanner.java | 73 ++++ .../iotdb/metricscrape/MetricScrapeService.java | 119 +++++++ .../iotdb/metricscrape/MetricScrapeTarget.java | 62 ++++ .../iotdb/metricscrape/MetricTableWriter.java | 211 +++++++++++ .../iotdb/metricscrape/PrometheusSample.java | 69 ++++ .../iotdb/metricscrape/PrometheusTextParser.java | 389 +++++++++++++++++++++ .../iotdb/metricscrape/ScheduledExecutorUtil.java | 53 +++ .../metricscrape/MetricScrapeConfigLoaderTest.java | 41 +++ .../metricscrape/PrometheusTextParserTest.java | 112 ++++++ pom.xml | 1 + 23 files changed, 2232 insertions(+), 4 deletions(-) diff --git a/README-zh.md b/README-zh.md index eaea49f..ffe425f 100644 --- a/README-zh.md +++ b/README-zh.md @@ -61,7 +61,9 @@ IoTDB (物联网数据库) 是一个专为物联网 (IoT) 场景设计的时序 5. **MyBatis 生成器**:数据库访问代码生成工具 -6. **示例**:展示 IoTDB 与各种技术结合使用的示例应用程序和代码示例 +6. **Metric Scrape**:用于 IoTDB 表模型的独立 Prometheus 文本格式指标抓取工具 + +7. **示例**:展示 IoTDB 与各种技术结合使用的示例应用程序和代码示例 ## 环境要求 @@ -83,7 +85,7 @@ IoTDB (物联网数据库) 是一个专为物联网 (IoT) 场景设计的时序 2. 使用 Maven 构建项目: ```bash - # 构建整个项目(包括 distributions、iotdb-collector、mybatis-generator) + # 构建整个项目(包括 distributions、iotdb-collector、metric-scrape、mybatis-generator) mvn clean package -DskipTests # 或者构建所有组件 @@ -310,6 +312,7 @@ mvn clean package -Pwith-all-connectors,with-examples,with-springboot -DskipTest - [Flink IoTDB 连接器](/connectors/flink-iotdb-connector/README.md) - [Grafana 插件](/connectors/grafana-plugin/README.md) - [IoTDB Spring Boot Starter](/iotdb-spring-boot-starter/README.md) +- [Metric Scrape](/metric-scrape/README.md) - [Kubernetes Helm Charts](/helm/README.md) - [IoTDB Operator](/iotdb-operator/README.md) diff --git a/README.md b/README.md index 4e2ad89..270ead1 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,9 @@ This repository includes: 5. **MyBatis Generator**: Code generation tools for database access -6. **Examples**: Sample applications and code examples demonstrating the use of IoTDB with various technologies +6. **Metric Scrape**: Standalone Prometheus text exposition scraper for the IoTDB table model + +7. **Examples**: Sample applications and code examples demonstrating the use of IoTDB with various technologies ## Prerequisites @@ -83,7 +85,7 @@ To build the project from source, follow these steps: 2. Build the project with Maven: ```bash - # Build the entire project (includes distributions,iotdb-collector,mybatis-generator) + # Build the entire project (includes distributions,iotdb-collector,metric-scrape,mybatis-generator) mvn clean package -DskipTests # Or build all @@ -312,6 +314,7 @@ You can also refer to module-specific documentation: - [Flink IoTDB Connector](/connectors/flink-iotdb-connector/README.md) - [Grafana Plugin](/connectors/grafana-plugin/README.md) - [IoTDB Spring Boot Starter](/iotdb-spring-boot-starter/README.md) +- [Metric Scrape](/metric-scrape/README.md) - [Kubernetes Helm Charts](/helm/README.md) - [IoTDB Operator](/iotdb-operator/README.md) diff --git a/metric-scrape/Dockerfile b/metric-scrape/Dockerfile new file mode 100644 index 0000000..4df24f6 --- /dev/null +++ b/metric-scrape/Dockerfile @@ -0,0 +1,26 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# + +FROM eclipse-temurin:17-jre-jammy + +WORKDIR /app + +COPY target/metric-scrape-2.0.4-SNAPSHOT.jar /app/metric-scrape.jar + +ENTRYPOINT ["java", "-jar", "/app/metric-scrape.jar", "-c", "/app/conf/metric-scrape.yml"] diff --git a/metric-scrape/README.md b/metric-scrape/README.md new file mode 100644 index 0000000..13cab17 --- /dev/null +++ b/metric-scrape/README.md @@ -0,0 +1,146 @@ +<!-- + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you 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. + +--> + +# Metric Scrape + +Metric Scrape is a standalone service that periodically scrapes Prometheus text exposition +endpoints and writes metrics into the Apache IoTDB table model through the +`org.apache.iotdb:iotdb-session` client. + +It is a pull scraper, not a Prometheus remote write receiver. + +## Build + +```bash +mvn -pl metric-scrape -am clean package -DskipTests +``` + +The runnable jar is generated at: + +```text +metric-scrape/target/metric-scrape-2.0.4-SNAPSHOT.jar +``` + +## Run + +```bash +java -jar metric-scrape/target/metric-scrape-2.0.4-SNAPSHOT.jar \ + -c metric-scrape/conf/metric-scrape.yml +``` + +If `-c` is omitted, the service reads `conf/metric-scrape.yml` from the current working +directory. + +## Configuration + +```yaml +global: + scrape_interval: 15s + scrape_timeout: 10s + iotdb_database: 'metrics' + iotdb_username: 'root' + iotdb_password: 'root' + iotdb_urls: ['127.0.0.1:6667'] + +scrape_configs: + - job_name: 'iotdbModel' + static_configs: + - targets: ['localhost:9090'] + metrics_path: /metrics + relabel_configs: + - target_label: k8s_cluster + replacement: example-cluster + - target_label: k8s_namespace + replacement: default + - target_label: instance_id + replacement: model-service-local +``` + +Supported fields: + +| Field | Description | +| --- | --- | +| `global.scrape_interval` | Default scrape interval. Supports `ms`, `s`, `m`, `h`; no suffix means seconds. | +| `global.scrape_timeout` | Default HTTP timeout. Supports the same duration format. | +| `global.iotdb_database` | Target IoTDB table-model database. | +| `global.iotdb_username` | Session username. | +| `global.iotdb_password` | Session password. | +| `global.iotdb_urls` | IoTDB node URLs, for example `127.0.0.1:6667`. | +| `global.write_batch_size` | Optional tablet batch size. Default is `1000`. | +| `scrape_configs[].job_name` | Scrape job name. It is written as tag column `job_name`. | +| `scrape_configs[].scheme` | Optional target scheme. Default is `http`. | +| `scrape_configs[].metrics_path` | Metrics path. Default is `/metrics`. | +| `scrape_configs[].scrape_interval` | Optional interval override for this job. | +| `scrape_configs[].scrape_timeout` | Optional timeout override for this job. | +| `scrape_configs[].static_configs[].targets` | Target host list. | +| `scrape_configs[].static_configs[].labels` | Extra labels for targets. They are written as tag columns. | +| `scrape_configs[].relabel_configs[].target_label` | Constant target label name. | +| `scrape_configs[].relabel_configs[].replacement` | Constant target label value. | + +Only constant relabeling with `target_label` and `replacement` is supported for now. + +## Data Model + +Metric Scrape writes into the IoTDB table model: + +| Prometheus content | Table model mapping | +| --- | --- | +| `global.iotdb_database` | Database name. | +| `# HELP` metric family name | Table name. | +| Sample metric name | Field column name. | +| Sample value | Field column value, `DOUBLE`. | +| `job_name` | Tag column. | +| Target address | Tag column `instance`. | +| Sample labels, static labels, relabel replacement labels | Tag columns, `STRING`. | +| Sample timestamp | Row time. If missing, the current scrape time in milliseconds is used. | + +For histogram and summary samples, the longest matching `# HELP` metric family is used as the +table name. For example, `request_duration_seconds_sum` and +`request_duration_seconds_count` are written into table `request_duration_seconds` if the text +contains `# HELP request_duration_seconds ...`. + +Metric names and label names are normalized before being used as table or column identifiers: +characters other than letters, digits, and `_` are replaced by `_`, and identifiers starting with +a digit get a leading `_`. + +## Docker And Kubernetes + +The module includes a `Dockerfile` and example Kubernetes manifests under `k8s/`. + +```bash +docker build -t metric-scrape:latest metric-scrape +kubectl apply -f metric-scrape/k8s/configmap.yaml +kubectl apply -f metric-scrape/k8s/deployment.yaml +``` + +Update the image name, IoTDB URLs, credentials, and scrape targets before deploying to a real +cluster. + +## Example Query + +```sql +USE metrics; + +SELECT time, job_name, instance, env, http_server_requests_seconds_count +FROM http_server_requests_seconds +ORDER BY time DESC +LIMIT 10; +``` diff --git a/metric-scrape/conf/metric-scrape.yml b/metric-scrape/conf/metric-scrape.yml new file mode 100644 index 0000000..645d62d --- /dev/null +++ b/metric-scrape/conf/metric-scrape.yml @@ -0,0 +1,61 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# + +global: + scrape_interval: 15s + scrape_timeout: 10s + iotdb_database: 'metrics' + iotdb_username: 'root' + iotdb_password: 'root' + iotdb_urls: ['127.0.0.1:6667'] + +scrape_configs: + - job_name: 'iotdbModel' + static_configs: + - targets: ['localhost:9090'] + metrics_path: /metrics + relabel_configs: + - target_label: k8s_cluster + replacement: example-cluster + - target_label: k8s_namespace + replacement: default + - target_label: instance_id + replacement: model-service-local + + - job_name: 'iotdbWeb' + metrics_path: /actuator/prometheus + static_configs: + - targets: ['localhost:8080'] + labels: {env: local, instance_id: web-service-local} + relabel_configs: + - target_label: k8s_cluster + replacement: example-cluster + - target_label: k8s_namespace + replacement: default + + - job_name: 'iotdbAdmin' + metrics_path: /actuator/prometheus + static_configs: + - targets: ['localhost:8081'] + labels: {env: local, instance_id: admin-service-local} + relabel_configs: + - target_label: k8s_cluster + replacement: example-cluster + - target_label: k8s_namespace + replacement: default diff --git a/metric-scrape/k8s/configmap.yaml b/metric-scrape/k8s/configmap.yaml new file mode 100644 index 0000000..00fcd59 --- /dev/null +++ b/metric-scrape/k8s/configmap.yaml @@ -0,0 +1,60 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# + +apiVersion: v1 +kind: ConfigMap +metadata: + name: metric-scrape-config + namespace: default + labels: + app.kubernetes.io/name: metric-scrape + app.kubernetes.io/part-of: iotdb-monitoring +data: + metric-scrape.yml: | + global: + scrape_interval: 30s + scrape_timeout: 10s + iotdb_database: 'metrics' + iotdb_username: 'root' + iotdb_password: 'root' + iotdb_urls: ['iotdb-service.default.svc.cluster.local:6667'] + + scrape_configs: + - job_name: 'iotdbModel' + metrics_path: /metrics + static_configs: + - targets: ['model-service.default.svc.cluster.local:9090'] + relabel_configs: + - target_label: k8s_cluster + replacement: example-cluster + - target_label: k8s_namespace + replacement: default + - target_label: instance_id + replacement: model-service + + - job_name: 'iotdbWeb' + metrics_path: /actuator/prometheus + static_configs: + - targets: ['web-service.default.svc.cluster.local:8080'] + labels: {env: example, instance_id: web-service} + relabel_configs: + - target_label: k8s_cluster + replacement: example-cluster + - target_label: k8s_namespace + replacement: default diff --git a/metric-scrape/k8s/deployment.yaml b/metric-scrape/k8s/deployment.yaml new file mode 100644 index 0000000..cbd23bd --- /dev/null +++ b/metric-scrape/k8s/deployment.yaml @@ -0,0 +1,64 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: metric-scrape + namespace: default + labels: + app.kubernetes.io/name: metric-scrape + app.kubernetes.io/part-of: iotdb-monitoring +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: metric-scrape + template: + metadata: + labels: + app.kubernetes.io/name: metric-scrape + app.kubernetes.io/part-of: iotdb-monitoring + annotations: + configmap/checksum: "v1" + spec: + containers: + - name: metric-scrape + image: metric-scrape:latest + imagePullPolicy: IfNotPresent + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 768Mi + volumeMounts: + - mountPath: /app/conf + name: config + readOnly: true + volumes: + - name: config + configMap: + name: metric-scrape-config + items: + - key: metric-scrape.yml + path: metric-scrape.yml diff --git a/metric-scrape/pom.xml b/metric-scrape/pom.xml new file mode 100644 index 0000000..b527546 --- /dev/null +++ b/metric-scrape/pom.xml @@ -0,0 +1,112 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you 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. + +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>org.apache.iotdb</groupId> + <artifactId>iotdb-extras-parent</artifactId> + <version>2.0.4-SNAPSHOT</version> + </parent> + <artifactId>metric-scrape</artifactId> + <name>IoTDB Extras: Metric Scrape</name> + <description>Standalone Prometheus text exposition scraper for the IoTDB table model.</description> + <dependencies> + <dependency> + <groupId>org.apache.iotdb</groupId> + <artifactId>isession</artifactId> + <version>${iotdb.version}</version> + </dependency> + <dependency> + <groupId>org.apache.iotdb</groupId> + <artifactId>iotdb-session</artifactId> + <version>${iotdb.version}</version> + </dependency> + <dependency> + <groupId>org.apache.iotdb</groupId> + <artifactId>service-rpc</artifactId> + <version>${iotdb.version}</version> + </dependency> + <dependency> + <groupId>org.apache.tsfile</groupId> + <artifactId>common</artifactId> + <version>${tsfile.version}</version> + </dependency> + <dependency> + <groupId>org.apache.tsfile</groupId> + <artifactId>tsfile</artifactId> + <version>${tsfile.version}</version> + </dependency> + <dependency> + <groupId>org.yaml</groupId> + <artifactId>snakeyaml</artifactId> + <version>2.0</version> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-simple</artifactId> + <version>${slf4j.version}</version> + <scope>runtime</scope> + </dependency> + <dependency> + <groupId>junit</groupId> + <artifactId>junit</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + <build> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-shade-plugin</artifactId> + <executions> + <execution> + <phase>package</phase> + <goals> + <goal>shade</goal> + </goals> + <configuration> + <createDependencyReducedPom>false</createDependencyReducedPom> + <transformers> + <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> + <mainClass>org.apache.iotdb.metricscrape.MetricScrapeMain</mainClass> + </transformer> + </transformers> + </configuration> + </execution> + </executions> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-dependency-plugin</artifactId> + <configuration> + <ignoredUnusedDeclaredDependencies> + <ignoredUnusedDeclaredDependency>org.slf4j:slf4j-simple</ignoredUnusedDeclaredDependency> + </ignoredUnusedDeclaredDependencies> + </configuration> + </plugin> + </plugins> + </build> +</project> diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/IdentifierUtils.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/IdentifierUtils.java new file mode 100644 index 0000000..21dc5fb --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/IdentifierUtils.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +public class IdentifierUtils { + + private IdentifierUtils() {} + + public static String sanitize(String identifier) { + if (identifier == null || identifier.trim().isEmpty()) { + return "unnamed"; + } + StringBuilder builder = new StringBuilder(identifier.length()); + for (int i = 0; i < identifier.length(); i++) { + char c = identifier.charAt(i); + if (Character.isLetterOrDigit(c) || c == '_') { + builder.append(c); + } else { + builder.append('_'); + } + } + if (builder.length() == 0) { + return "unnamed"; + } + if (Character.isDigit(builder.charAt(0))) { + builder.insert(0, '_'); + } + return builder.toString(); + } + + public static String quoteSqlIdentifier(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeConfig.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeConfig.java new file mode 100644 index 0000000..116afe9 --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeConfig.java @@ -0,0 +1,195 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class MetricScrapeConfig { + + private final GlobalConfig global; + private final List<ScrapeConfig> scrapeConfigs; + + public MetricScrapeConfig(GlobalConfig global, List<ScrapeConfig> scrapeConfigs) { + this.global = global; + this.scrapeConfigs = copyList(scrapeConfigs); + } + + public GlobalConfig getGlobal() { + return global; + } + + public List<ScrapeConfig> getScrapeConfigs() { + return scrapeConfigs; + } + + public static class GlobalConfig { + private final Duration scrapeInterval; + private final Duration scrapeTimeout; + private final String databaseName; + private final String username; + private final String password; + private final List<String> nodeUrls; + private final int writeBatchSize; + + public GlobalConfig( + Duration scrapeInterval, + Duration scrapeTimeout, + String databaseName, + String username, + String password, + List<String> nodeUrls, + int writeBatchSize) { + this.scrapeInterval = scrapeInterval; + this.scrapeTimeout = scrapeTimeout; + this.databaseName = databaseName; + this.username = username; + this.password = password; + this.nodeUrls = copyList(nodeUrls); + this.writeBatchSize = writeBatchSize; + } + + public Duration getScrapeInterval() { + return scrapeInterval; + } + + public Duration getScrapeTimeout() { + return scrapeTimeout; + } + + public String getDatabaseName() { + return databaseName; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public List<String> getNodeUrls() { + return nodeUrls; + } + + public int getWriteBatchSize() { + return writeBatchSize; + } + } + + public static class ScrapeConfig { + private final String jobName; + private final String scheme; + private final String metricsPath; + private final Duration scrapeInterval; + private final Duration scrapeTimeout; + private final List<StaticConfig> staticConfigs; + private final List<RelabelConfig> relabelConfigs; + + public ScrapeConfig( + String jobName, + String scheme, + String metricsPath, + Duration scrapeInterval, + Duration scrapeTimeout, + List<StaticConfig> staticConfigs, + List<RelabelConfig> relabelConfigs) { + this.jobName = jobName; + this.scheme = scheme; + this.metricsPath = metricsPath; + this.scrapeInterval = scrapeInterval; + this.scrapeTimeout = scrapeTimeout; + this.staticConfigs = copyList(staticConfigs); + this.relabelConfigs = copyList(relabelConfigs); + } + + public String getJobName() { + return jobName; + } + + public String getScheme() { + return scheme; + } + + public String getMetricsPath() { + return metricsPath; + } + + public Duration getScrapeInterval() { + return scrapeInterval; + } + + public Duration getScrapeTimeout() { + return scrapeTimeout; + } + + public List<StaticConfig> getStaticConfigs() { + return staticConfigs; + } + + public List<RelabelConfig> getRelabelConfigs() { + return relabelConfigs; + } + } + + public static class StaticConfig { + private final List<String> targets; + private final Map<String, String> labels; + + public StaticConfig(List<String> targets, Map<String, String> labels) { + this.targets = copyList(targets); + this.labels = Collections.unmodifiableMap(new LinkedHashMap<>(labels)); + } + + public List<String> getTargets() { + return targets; + } + + public Map<String, String> getLabels() { + return labels; + } + } + + public static class RelabelConfig { + private final String targetLabel; + private final String replacement; + + public RelabelConfig(String targetLabel, String replacement) { + this.targetLabel = targetLabel; + this.replacement = replacement; + } + + public String getTargetLabel() { + return targetLabel; + } + + public String getReplacement() { + return replacement; + } + } + + private static <T> List<T> copyList(List<T> values) { + return Collections.unmodifiableList(new ArrayList<>(values)); + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeConfigLoader.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeConfigLoader.java new file mode 100644 index 0000000..ea0d5e6 --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeConfigLoader.java @@ -0,0 +1,256 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import org.apache.iotdb.metricscrape.MetricScrapeConfig.GlobalConfig; +import org.apache.iotdb.metricscrape.MetricScrapeConfig.RelabelConfig; +import org.apache.iotdb.metricscrape.MetricScrapeConfig.ScrapeConfig; +import org.apache.iotdb.metricscrape.MetricScrapeConfig.StaticConfig; + +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class MetricScrapeConfigLoader { + + private static final Duration DEFAULT_SCRAPE_INTERVAL = Duration.ofSeconds(15); + private static final Duration DEFAULT_SCRAPE_TIMEOUT = Duration.ofSeconds(10); + private static final String DEFAULT_METRICS_PATH = "/metrics"; + private static final String DEFAULT_SCHEME = "http"; + private static final int DEFAULT_WRITE_BATCH_SIZE = 1000; + + private MetricScrapeConfigLoader() {} + + public static MetricScrapeConfig load(Path path) throws IOException { + try (InputStream inputStream = Files.newInputStream(path)) { + Object loaded = new Yaml().load(inputStream); + if (!(loaded instanceof Map)) { + throw new IllegalArgumentException("YAML root should be a map"); + } + return parseConfig(castMap(loaded, "root")); + } + } + + private static MetricScrapeConfig parseConfig(Map<String, Object> root) { + Map<String, Object> globalMap = castMap(required(root, "global"), "global"); + GlobalConfig global = parseGlobal(globalMap); + List<ScrapeConfig> scrapeConfigs = parseScrapeConfigs(required(root, "scrape_configs"), global); + if (scrapeConfigs.isEmpty()) { + throw new IllegalArgumentException("scrape_configs should not be empty"); + } + return new MetricScrapeConfig(global, scrapeConfigs); + } + + private static GlobalConfig parseGlobal(Map<String, Object> globalMap) { + Duration interval = + parseDuration(optional(globalMap, "scrape_interval"), DEFAULT_SCRAPE_INTERVAL); + Duration timeout = parseDuration(optional(globalMap, "scrape_timeout"), DEFAULT_SCRAPE_TIMEOUT); + String databaseName = requiredString(globalMap, "iotdb_database"); + String username = stringValue(optional(globalMap, "iotdb_username"), "root"); + String password = stringValue(optional(globalMap, "iotdb_password"), "root"); + List<String> nodeUrls = parseStringList(required(globalMap, "iotdb_urls"), "iotdb_urls"); + int batchSize = + parsePositiveInt(optional(globalMap, "write_batch_size"), DEFAULT_WRITE_BATCH_SIZE); + return new GlobalConfig( + interval, timeout, databaseName, username, password, nodeUrls, batchSize); + } + + private static List<ScrapeConfig> parseScrapeConfigs(Object value, GlobalConfig global) { + List<Object> configs = castList(value, "scrape_configs"); + List<ScrapeConfig> result = new ArrayList<>(); + for (int i = 0; i < configs.size(); i++) { + Map<String, Object> map = castMap(configs.get(i), "scrape_configs[" + i + "]"); + String jobName = requiredString(map, "job_name"); + String scheme = stringValue(optional(map, "scheme"), DEFAULT_SCHEME); + String metricsPath = + normalizeMetricsPath(stringValue(optional(map, "metrics_path"), DEFAULT_METRICS_PATH)); + Duration interval = + parseDuration(optional(map, "scrape_interval"), global.getScrapeInterval()); + Duration timeout = parseDuration(optional(map, "scrape_timeout"), global.getScrapeTimeout()); + List<StaticConfig> staticConfigs = parseStaticConfigs(required(map, "static_configs")); + List<RelabelConfig> relabelConfigs = parseRelabelConfigs(optional(map, "relabel_configs")); + result.add( + new ScrapeConfig( + jobName, scheme, metricsPath, interval, timeout, staticConfigs, relabelConfigs)); + } + return result; + } + + private static List<StaticConfig> parseStaticConfigs(Object value) { + List<Object> configs = castList(value, "static_configs"); + List<StaticConfig> result = new ArrayList<>(); + for (int i = 0; i < configs.size(); i++) { + Map<String, Object> map = castMap(configs.get(i), "static_configs[" + i + "]"); + List<String> targets = parseStringList(required(map, "targets"), "targets"); + Map<String, String> labels = parseStringMap(optional(map, "labels")); + result.add(new StaticConfig(targets, labels)); + } + return result; + } + + private static List<RelabelConfig> parseRelabelConfigs(Object value) { + if (value == null) { + return Collections.emptyList(); + } + List<Object> configs = castList(value, "relabel_configs"); + List<RelabelConfig> result = new ArrayList<>(); + for (int i = 0; i < configs.size(); i++) { + Map<String, Object> map = castMap(configs.get(i), "relabel_configs[" + i + "]"); + String targetLabel = requiredString(map, "target_label"); + String replacement = stringValue(optional(map, "replacement"), ""); + result.add(new RelabelConfig(targetLabel, replacement)); + } + return result; + } + + private static Object required(Map<String, Object> map, String key) { + Object value = map.get(key); + if (value == null) { + throw new IllegalArgumentException("Missing required config: " + key); + } + return value; + } + + private static Object optional(Map<String, Object> map, String key) { + return map.get(key); + } + + private static String requiredString(Map<String, Object> map, String key) { + String value = stringValue(required(map, key), null); + if (isBlank(value)) { + throw new IllegalArgumentException("Config " + key + " should not be empty"); + } + return value; + } + + private static String stringValue(Object value, String defaultValue) { + return value == null ? defaultValue : String.valueOf(value); + } + + private static String normalizeMetricsPath(String path) { + if (isBlank(path)) { + return DEFAULT_METRICS_PATH; + } + return path.startsWith("/") ? path : "/" + path; + } + + private static Duration parseDuration(Object value, Duration defaultValue) { + if (value == null) { + return defaultValue; + } + String text = String.valueOf(value).trim().toLowerCase(Locale.ROOT); + if (text.isEmpty()) { + return defaultValue; + } + long multiplier; + String number; + if (text.endsWith("ms")) { + multiplier = 1; + number = text.substring(0, text.length() - 2); + } else if (text.endsWith("s")) { + multiplier = 1000; + number = text.substring(0, text.length() - 1); + } else if (text.endsWith("m")) { + multiplier = 60_000; + number = text.substring(0, text.length() - 1); + } else if (text.endsWith("h")) { + multiplier = 3_600_000; + number = text.substring(0, text.length() - 1); + } else { + multiplier = 1000; + number = text; + } + try { + long amount = Long.parseLong(number); + if (amount <= 0) { + throw new IllegalArgumentException("Duration should be positive: " + value); + } + return Duration.ofMillis(amount * multiplier); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Illegal duration: " + value, e); + } + } + + private static int parsePositiveInt(Object value, int defaultValue) { + if (value == null) { + return defaultValue; + } + int parsed = Integer.parseInt(String.valueOf(value)); + if (parsed <= 0) { + throw new IllegalArgumentException("Value should be positive: " + value); + } + return parsed; + } + + private static List<String> parseStringList(Object value, String name) { + List<Object> rawList = castList(value, name); + List<String> result = new ArrayList<>(); + for (Object item : rawList) { + String text = String.valueOf(item); + if (isBlank(text)) { + throw new IllegalArgumentException(name + " contains empty value"); + } + result.add(text); + } + return result; + } + + private static Map<String, String> parseStringMap(Object value) { + if (value == null) { + return Collections.emptyMap(); + } + Map<String, Object> rawMap = castMap(value, "labels"); + Map<String, String> result = new LinkedHashMap<>(); + for (Map.Entry<String, Object> entry : rawMap.entrySet()) { + result.put(entry.getKey(), String.valueOf(entry.getValue())); + } + return result; + } + + @SuppressWarnings("unchecked") + private static Map<String, Object> castMap(Object value, String name) { + if (!(value instanceof Map)) { + throw new IllegalArgumentException(name + " should be a map"); + } + Map<String, Object> result = new LinkedHashMap<>(); + ((Map<?, ?>) value).forEach((key, item) -> result.put(String.valueOf(key), item)); + return result; + } + + private static List<Object> castList(Object value, String name) { + if (!(value instanceof List)) { + throw new IllegalArgumentException(name + " should be a list"); + } + return new ArrayList<>((List<?>) value); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeHttpClient.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeHttpClient.java new file mode 100644 index 0000000..267c55b --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeHttpClient.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +public class MetricScrapeHttpClient { + + public String get(String url, int timeoutMs) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(timeoutMs); + connection.setReadTimeout(timeoutMs); + connection.setRequestProperty( + "Accept", "text/plain; version=0.0.4, application/openmetrics-text, */*"); + connection.setRequestProperty("User-Agent", "Apache-IoTDB-MetricScrape/1.0"); + + int statusCode = connection.getResponseCode(); + if (statusCode < 200 || statusCode >= 300) { + throw new IOException("Metric scrape target " + url + " returns HTTP " + statusCode); + } + try (InputStream inputStream = connection.getInputStream()) { + return readToString(inputStream); + } finally { + connection.disconnect(); + } + } + + private String readToString(InputStream inputStream) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int length; + while ((length = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, length); + } + return new String(outputStream.toByteArray(), StandardCharsets.UTF_8); + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeMain.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeMain.java new file mode 100644 index 0000000..2885f05 --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeMain.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.CountDownLatch; + +public class MetricScrapeMain { + + private static final Logger LOGGER = LoggerFactory.getLogger(MetricScrapeMain.class); + private static final String DEFAULT_CONFIG = "conf/metric-scrape.yml"; + + public static void main(String[] args) throws Exception { + Path configPath = parseConfigPath(args); + MetricScrapeConfig config = MetricScrapeConfigLoader.load(configPath); + MetricScrapeService service = new MetricScrapeService(config); + CountDownLatch stopLatch = new CountDownLatch(1); + + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + LOGGER.info("Stopping metric scrape service."); + service.stop(); + stopLatch.countDown(); + }, + "metric-scrape-shutdown")); + + service.start(); + LOGGER.info("Metric scrape service started with config {}.", configPath); + stopLatch.await(); + } + + private static Path parseConfigPath(String[] args) { + if (args.length == 0) { + return Paths.get(DEFAULT_CONFIG); + } + if (args.length == 2 && ("-c".equals(args[0]) || "--config".equals(args[0]))) { + return Paths.get(args[1]); + } + throw new IllegalArgumentException("Usage: java -jar metric-scrape.jar [-c config.yml]"); + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapePlanner.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapePlanner.java new file mode 100644 index 0000000..42e48af --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapePlanner.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import org.apache.iotdb.metricscrape.MetricScrapeConfig.RelabelConfig; +import org.apache.iotdb.metricscrape.MetricScrapeConfig.ScrapeConfig; +import org.apache.iotdb.metricscrape.MetricScrapeConfig.StaticConfig; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class MetricScrapePlanner { + + public List<MetricScrapeTarget> buildTargets(MetricScrapeConfig config) { + List<MetricScrapeTarget> result = new ArrayList<>(); + for (ScrapeConfig scrapeConfig : config.getScrapeConfigs()) { + for (StaticConfig staticConfig : scrapeConfig.getStaticConfigs()) { + for (String target : staticConfig.getTargets()) { + Map<String, String> labels = new LinkedHashMap<>(); + labels.put("job_name", scrapeConfig.getJobName()); + labels.put("instance", target); + labels.putAll(staticConfig.getLabels()); + for (RelabelConfig relabelConfig : scrapeConfig.getRelabelConfigs()) { + labels.put(relabelConfig.getTargetLabel(), relabelConfig.getReplacement()); + } + result.add( + new MetricScrapeTarget( + scrapeConfig.getJobName(), + buildUrl(scrapeConfig, target), + scrapeConfig.getScrapeInterval(), + scrapeConfig.getScrapeTimeout(), + labels)); + } + } + } + return result; + } + + private String buildUrl(ScrapeConfig scrapeConfig, String target) { + if (target.startsWith("http://") || target.startsWith("https://")) { + return appendMetricsPath(target, scrapeConfig.getMetricsPath()); + } + return scrapeConfig.getScheme() + "://" + target + scrapeConfig.getMetricsPath(); + } + + private String appendMetricsPath(String target, String metricsPath) { + if (target.endsWith(metricsPath)) { + return target; + } + if (target.endsWith("/")) { + return target.substring(0, target.length() - 1) + metricsPath; + } + return target + metricsPath; + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeService.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeService.java new file mode 100644 index 0000000..466469b --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeService.java @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public class MetricScrapeService { + + private static final Logger LOGGER = LoggerFactory.getLogger(MetricScrapeService.class); + + private final MetricScrapeConfig config; + private final MetricScrapeHttpClient httpClient = new MetricScrapeHttpClient(); + private final PrometheusTextParser parser = new PrometheusTextParser(); + private final MetricScrapePlanner planner = new MetricScrapePlanner(); + private MetricTableWriter writer; + private ScheduledExecutorService executorService; + + public MetricScrapeService(MetricScrapeConfig config) { + this.config = config; + } + + public synchronized void start() throws Exception { + List<MetricScrapeTarget> targets = planner.buildTargets(config); + if (targets.isEmpty()) { + throw new IllegalArgumentException("No metric scrape target configured"); + } + writer = new MetricTableWriter(config.getGlobal()); + writer.initializeDatabase(); + AtomicInteger threadIndex = new AtomicInteger(); + executorService = + Executors.newScheduledThreadPool( + Math.min(targets.size(), Runtime.getRuntime().availableProcessors() * 2), + runnable -> { + Thread thread = + new Thread(runnable, "metric-scrape-" + threadIndex.incrementAndGet()); + thread.setDaemon(false); + return thread; + }); + for (MetricScrapeTarget target : targets) { + ScheduledExecutorUtil.safelyScheduleWithFixedDelay( + executorService, + () -> scrapeTarget(target), + 0, + target.getInterval().toMillis(), + TimeUnit.MILLISECONDS); + LOGGER.info( + "Scheduled metric target {} with interval {}.", target.getUrl(), target.getInterval()); + } + } + + public synchronized void stop() { + if (executorService != null) { + executorService.shutdownNow(); + executorService = null; + } + if (writer != null) { + writer.close(); + writer = null; + } + } + + private void scrapeTarget(MetricScrapeTarget target) { + try { + String text = + httpClient.get(target.getUrl(), Math.toIntExact(target.getTimeout().toMillis())); + List<PrometheusSample> samples = parser.parse(text, System.currentTimeMillis()); + writer.write(enrichSamples(samples, target)); + LOGGER.debug("Scraped {} samples from {}.", samples.size(), target.getUrl()); + } catch (IOException | RuntimeException e) { + LOGGER.warn("Failed to scrape metric target {}.", target.getUrl(), e); + } catch (Exception e) { + LOGGER.warn("Failed to write metric samples from {}.", target.getUrl(), e); + } + } + + private List<PrometheusSample> enrichSamples( + List<PrometheusSample> samples, MetricScrapeTarget target) { + List<PrometheusSample> result = new ArrayList<>(samples.size()); + for (PrometheusSample sample : samples) { + result.add(sample.withLabels(mergeLabels(target.getLabels(), sample.getLabels()))); + } + return result; + } + + private Map<String, String> mergeLabels( + Map<String, String> targetLabels, Map<String, String> sampleLabels) { + java.util.LinkedHashMap<String, String> labels = new java.util.LinkedHashMap<>(targetLabels); + for (Map.Entry<String, String> entry : sampleLabels.entrySet()) { + labels.putIfAbsent(entry.getKey(), entry.getValue()); + } + return labels; + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeTarget.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeTarget.java new file mode 100644 index 0000000..2519987 --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricScrapeTarget.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class MetricScrapeTarget { + + private final String jobName; + private final String url; + private final Duration interval; + private final Duration timeout; + private final Map<String, String> labels; + + public MetricScrapeTarget( + String jobName, String url, Duration interval, Duration timeout, Map<String, String> labels) { + this.jobName = jobName; + this.url = url; + this.interval = interval; + this.timeout = timeout; + this.labels = Collections.unmodifiableMap(new LinkedHashMap<>(labels)); + } + + public String getJobName() { + return jobName; + } + + public String getUrl() { + return url; + } + + public Duration getInterval() { + return interval; + } + + public Duration getTimeout() { + return timeout; + } + + public Map<String, String> getLabels() { + return labels; + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricTableWriter.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricTableWriter.java new file mode 100644 index 0000000..a0df465 --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/MetricTableWriter.java @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import org.apache.iotdb.isession.ITableSession; +import org.apache.iotdb.metricscrape.MetricScrapeConfig.GlobalConfig; +import org.apache.iotdb.rpc.IoTDBConnectionException; +import org.apache.iotdb.rpc.StatementExecutionException; +import org.apache.iotdb.session.TableSessionBuilder; + +import org.apache.tsfile.enums.ColumnCategory; +import org.apache.tsfile.enums.TSDataType; +import org.apache.tsfile.write.record.Tablet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class MetricTableWriter { + + private static final Logger LOGGER = LoggerFactory.getLogger(MetricTableWriter.class); + + private final GlobalConfig config; + private final ITableSession session; + + public MetricTableWriter(GlobalConfig config) throws IoTDBConnectionException { + this.config = config; + this.session = + new TableSessionBuilder() + .nodeUrls(config.getNodeUrls()) + .username(config.getUsername()) + .password(config.getPassword()) + .database(config.getDatabaseName()) + .build(); + } + + public synchronized void initializeDatabase() + throws IoTDBConnectionException, StatementExecutionException { + session.executeNonQueryStatement( + "CREATE DATABASE IF NOT EXISTS " + + IdentifierUtils.quoteSqlIdentifier(config.getDatabaseName())); + } + + public synchronized void write(List<PrometheusSample> samples) + throws IoTDBConnectionException, StatementExecutionException { + if (samples.isEmpty()) { + return; + } + Map<TabletKey, TabletBuilder> builders = new LinkedHashMap<>(); + for (PrometheusSample sample : samples) { + TabletKey key = TabletKey.from(sample); + TabletBuilder builder = + builders.computeIfAbsent( + key, ignored -> new TabletBuilder(key, config.getWriteBatchSize())); + builder.add(sample); + if (builder.isFull()) { + flush(builder); + builder.reset(); + } + } + for (TabletBuilder builder : builders.values()) { + if (!builder.isEmpty()) { + flush(builder); + } + } + } + + public void close() { + try { + session.close(); + } catch (IoTDBConnectionException e) { + LOGGER.warn("Failed to close IoTDB session.", e); + } + } + + private void flush(TabletBuilder builder) + throws IoTDBConnectionException, StatementExecutionException { + session.insert(builder.tablet); + } + + private static class TabletKey { + private final String table; + private final String field; + private final List<String> labelKeys; + private final Map<String, String> sanitizedToOriginalLabel; + + private static TabletKey from(PrometheusSample sample) { + Map<String, String> sanitizedToOriginalLabel = sanitizeLabels(sample); + List<String> labelKeys = new ArrayList<>(sanitizedToOriginalLabel.keySet()); + return new TabletKey( + IdentifierUtils.sanitize(sample.getMetricFamilyName()), + IdentifierUtils.sanitize(sample.getMetricName()), + labelKeys, + sanitizedToOriginalLabel); + } + + private static Map<String, String> sanitizeLabels(PrometheusSample sample) { + List<String> originalLabels = new ArrayList<>(sample.getLabels().keySet()); + Collections.sort(originalLabels); + Map<String, String> result = new LinkedHashMap<>(); + for (String originalLabel : originalLabels) { + String sanitizedLabel = IdentifierUtils.sanitize(originalLabel); + String previous = result.put(sanitizedLabel, originalLabel); + if (previous != null && !previous.equals(originalLabel)) { + throw new IllegalArgumentException( + "Prometheus labels " + + previous + + " and " + + originalLabel + + " map to the same IoTDB column " + + sanitizedLabel); + } + } + return result; + } + + private TabletKey( + String table, + String field, + List<String> labelKeys, + Map<String, String> sanitizedToOriginalLabel) { + this.table = table; + this.field = field; + this.labelKeys = Collections.unmodifiableList(new ArrayList<>(labelKeys)); + this.sanitizedToOriginalLabel = + Collections.unmodifiableMap(new LinkedHashMap<>(sanitizedToOriginalLabel)); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TabletKey)) { + return false; + } + TabletKey tabletKey = (TabletKey) o; + return Objects.equals(table, tabletKey.table) + && Objects.equals(field, tabletKey.field) + && Objects.equals(labelKeys, tabletKey.labelKeys); + } + + @Override + public int hashCode() { + return Objects.hash(table, field, labelKeys); + } + } + + private static class TabletBuilder { + private final TabletKey key; + private final Tablet tablet; + + private TabletBuilder(TabletKey key, int maxRows) { + this.key = key; + List<String> columnNames = new ArrayList<>(key.labelKeys); + columnNames.add(key.field); + List<TSDataType> dataTypes = new ArrayList<>(columnNames.size()); + List<ColumnCategory> columnCategories = new ArrayList<>(columnNames.size()); + for (int i = 0; i < key.labelKeys.size(); i++) { + dataTypes.add(TSDataType.STRING); + columnCategories.add(ColumnCategory.TAG); + } + dataTypes.add(TSDataType.DOUBLE); + columnCategories.add(ColumnCategory.FIELD); + this.tablet = new Tablet(key.table, columnNames, dataTypes, columnCategories, maxRows); + } + + private boolean isFull() { + return tablet.getRowSize() == tablet.getMaxRowNumber(); + } + + private boolean isEmpty() { + return tablet.getRowSize() == 0; + } + + private void reset() { + tablet.reset(); + } + + private void add(PrometheusSample sample) { + int row = tablet.getRowSize(); + tablet.addTimestamp(row, sample.getTimestamp()); + for (String labelKey : key.labelKeys) { + String originalLabel = key.sanitizedToOriginalLabel.get(labelKey); + tablet.addValue(labelKey, row, sample.getLabels().get(originalLabel)); + } + tablet.addValue(key.field, row, sample.getValue()); + } + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/PrometheusSample.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/PrometheusSample.java new file mode 100644 index 0000000..7e8a5f8 --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/PrometheusSample.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class PrometheusSample { + + private final String metricFamilyName; + private final String metricName; + private final Map<String, String> labels; + private final double value; + private final long timestamp; + + public PrometheusSample( + String metricFamilyName, + String metricName, + Map<String, String> labels, + double value, + long timestamp) { + this.metricFamilyName = metricFamilyName; + this.metricName = metricName; + this.labels = Collections.unmodifiableMap(new LinkedHashMap<>(labels)); + this.value = value; + this.timestamp = timestamp; + } + + public String getMetricFamilyName() { + return metricFamilyName; + } + + public String getMetricName() { + return metricName; + } + + public Map<String, String> getLabels() { + return labels; + } + + public double getValue() { + return value; + } + + public long getTimestamp() { + return timestamp; + } + + public PrometheusSample withLabels(Map<String, String> newLabels) { + return new PrometheusSample(metricFamilyName, metricName, newLabels, value, timestamp); + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/PrometheusTextParser.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/PrometheusTextParser.java new file mode 100644 index 0000000..c328dd1 --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/PrometheusTextParser.java @@ -0,0 +1,389 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +public class PrometheusTextParser { + + private static final String[] METRIC_FAMILY_SAMPLE_SUFFIXES = { + "_sum", "_count", "_bucket", "_created" + }; + + public List<PrometheusSample> parse(String text, long defaultTimestamp) { + if (text == null) { + throw new IllegalArgumentException("Prometheus text should not be null"); + } + List<PrometheusSample> samples = new ArrayList<>(); + String[] lines = text.split("\\r\\n|\\n|\\r"); + Set<String> helpMetricNames = collectHelpMetricNames(lines); + for (int i = 0; i < lines.length; i++) { + String line = lines[i].trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + samples.add(parseSample(line, i + 1, defaultTimestamp, helpMetricNames)); + } + return samples; + } + + private PrometheusSample parseSample( + String line, int lineNumber, long defaultTimestamp, Set<String> helpMetricNames) { + Parser parser = new Parser(line, lineNumber); + String metricName = parser.parseMetricName(); + Map<String, String> labels = parser.parseLabels(); + parser.skipWhitespace(); + double value = parser.parseValue(); + parser.skipWhitespace(); + long timestamp = parser.parseOptionalTimestamp(defaultTimestamp); + parser.skipWhitespace(); + parser.expectEnd(); + return new PrometheusSample( + resolveMetricFamilyName(metricName, helpMetricNames), metricName, labels, value, timestamp); + } + + private static Set<String> collectHelpMetricNames(String[] lines) { + Set<String> helpMetricNames = new LinkedHashSet<>(); + for (String rawLine : lines) { + String line = rawLine.trim(); + if (!line.startsWith("# HELP")) { + continue; + } + int position = "# HELP".length(); + while (position < line.length() && Character.isWhitespace(line.charAt(position))) { + position++; + } + int start = position; + while (position < line.length() && !Character.isWhitespace(line.charAt(position))) { + position++; + } + if (start < position) { + helpMetricNames.add(line.substring(start, position)); + } + } + return helpMetricNames; + } + + private static String resolveMetricFamilyName(String metricName, Set<String> helpMetricNames) { + if (helpMetricNames.contains(metricName)) { + return metricName; + } + + String result = null; + for (String helpMetricName : helpMetricNames) { + if (isMetricFamilyComponent(metricName, helpMetricName) + && (result == null || helpMetricName.length() > result.length())) { + result = helpMetricName; + } + } + return result == null ? metricName : result; + } + + private static boolean isMetricFamilyComponent(String metricName, String helpMetricName) { + for (String suffix : METRIC_FAMILY_SAMPLE_SUFFIXES) { + if (metricName.equals(helpMetricName + suffix)) { + return true; + } + } + return false; + } + + private static double parsePrometheusDouble(String token, int lineNumber) { + String lowerToken = token.toLowerCase(Locale.ROOT); + switch (lowerToken) { + case "inf": + case "+inf": + case "infinity": + case "+infinity": + return Double.POSITIVE_INFINITY; + case "-inf": + case "-infinity": + return Double.NEGATIVE_INFINITY; + case "nan": + case "+nan": + case "-nan": + return Double.NaN; + default: + break; + } + if (token.endsWith("d") || token.endsWith("D") || token.endsWith("f") || token.endsWith("F")) { + throw new IllegalArgumentException( + "Illegal Prometheus sample value " + token + " at line " + lineNumber); + } + try { + return Double.parseDouble(removeValidNumericUnderscores(token, lineNumber)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Illegal Prometheus sample value " + token + " at line " + lineNumber, e); + } + } + + private static String removeValidNumericUnderscores(String token, int lineNumber) { + if (token.indexOf('_') < 0) { + return token; + } + for (int i = 0; i < token.length(); i++) { + if (token.charAt(i) == '_') { + if (i == 0 + || i == token.length() - 1 + || !Character.isDigit(token.charAt(i - 1)) + || !Character.isDigit(token.charAt(i + 1))) { + throw new IllegalArgumentException( + "Illegal Prometheus sample value " + token + " at line " + lineNumber); + } + } + } + return token.replace("_", ""); + } + + private static class Parser { + private final String line; + private final int lineNumber; + private int position; + + private Parser(String line, int lineNumber) { + this.line = line; + this.lineNumber = lineNumber; + } + + private String parseMetricName() { + if (position >= line.length() || Character.isWhitespace(line.charAt(position))) { + throw new IllegalArgumentException("Illegal Prometheus metric name at line " + lineNumber); + } + int start = position; + while (position < line.length() + && !Character.isWhitespace(line.charAt(position)) + && line.charAt(position) != '{') { + position++; + } + if (start == position) { + throw new IllegalArgumentException("Illegal Prometheus metric name at line " + lineNumber); + } + return line.substring(start, position); + } + + private Map<String, String> parseLabels() { + Map<String, String> labels = new LinkedHashMap<>(); + if (position >= line.length() || line.charAt(position) != '{') { + return labels; + } + position++; + skipWhitespace(); + if (position < line.length() && line.charAt(position) == '}') { + position++; + return labels; + } + + while (position < line.length()) { + String name = parseLabelName(); + skipWhitespace(); + expect('='); + skipWhitespace(); + String value = parseLabelValue(); + if (labels.put(name, value) != null) { + throw new IllegalArgumentException( + "Duplicate Prometheus label " + name + " at line " + lineNumber); + } + skipWhitespace(); + if (position < line.length() && line.charAt(position) == ',') { + position++; + skipWhitespace(); + if (position < line.length() && line.charAt(position) == '}') { + position++; + return labels; + } + continue; + } + expect('}'); + return labels; + } + throw new IllegalArgumentException("Unclosed Prometheus label set at line " + lineNumber); + } + + private String parseLabelName() { + int start = position; + while (position < line.length() && line.charAt(position) != '=') { + if (line.charAt(position) == ',' || line.charAt(position) == '}') { + break; + } + position++; + } + String name = line.substring(start, position).trim(); + if (name.isEmpty()) { + throw new IllegalArgumentException("Illegal Prometheus label name at line " + lineNumber); + } + return name; + } + + private String parseLabelValue() { + expect('"'); + StringBuilder builder = new StringBuilder(); + int delimiterDepth = 0; + while (position < line.length()) { + char c = line.charAt(position++); + if (c == '"') { + if (delimiterDepth == 0 && isLabelValueTerminator()) { + return builder.toString(); + } + builder.append(c); + continue; + } + if (c == '\\') { + if (position >= line.length()) { + throw new IllegalArgumentException( + "Illegal Prometheus label escape at line " + lineNumber); + } + char escaped = line.charAt(position++); + switch (escaped) { + case 'n': + builder.append('\n'); + break; + case '\\': + case '"': + builder.append(escaped); + break; + default: + throw new IllegalArgumentException( + "Illegal Prometheus label escape at line " + lineNumber); + } + } else { + builder.append(c); + delimiterDepth = updateDelimiterDepth(delimiterDepth, c); + } + } + throw new IllegalArgumentException("Unclosed Prometheus label value at line " + lineNumber); + } + + private int updateDelimiterDepth(int delimiterDepth, char c) { + switch (c) { + case '{': + case '[': + case '(': + return delimiterDepth + 1; + case '}': + case ']': + case ')': + return Math.max(0, delimiterDepth - 1); + default: + return delimiterDepth; + } + } + + private boolean isLabelValueTerminator() { + int next = skipWhitespace(position); + if (next >= line.length()) { + return true; + } + if (line.charAt(next) == '}') { + return next + 1 >= line.length() || Character.isWhitespace(line.charAt(next + 1)); + } + if (line.charAt(next) != ',') { + return false; + } + int afterComma = skipWhitespace(next + 1); + return afterComma >= line.length() + || line.charAt(afterComma) == '}' + || isLabelAssignmentStart(afterComma); + } + + private boolean isLabelAssignmentStart(int start) { + if (start >= line.length() || line.charAt(start) == '"') { + return false; + } + int scan = start; + while (scan < line.length()) { + char c = line.charAt(scan); + if (c == '=') { + String labelName = line.substring(start, scan).trim(); + int valueStart = skipWhitespace(scan + 1); + return !labelName.isEmpty() + && valueStart < line.length() + && line.charAt(valueStart) == '"'; + } + if (c == ',' || c == '}') { + return false; + } + scan++; + } + return false; + } + + private double parseValue() { + return parsePrometheusDouble(parseToken("sample value"), lineNumber); + } + + private long parseOptionalTimestamp(long defaultTimestamp) { + if (position >= line.length()) { + return defaultTimestamp; + } + String token = parseToken("sample timestamp"); + try { + return Long.parseLong(token); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Illegal Prometheus sample timestamp " + token + " at line " + lineNumber, e); + } + } + + private String parseToken(String name) { + if (position >= line.length() || Character.isWhitespace(line.charAt(position))) { + throw new IllegalArgumentException("Missing Prometheus " + name + " at line " + lineNumber); + } + int start = position; + while (position < line.length() && !Character.isWhitespace(line.charAt(position))) { + position++; + } + return line.substring(start, position); + } + + private void skipWhitespace() { + while (position < line.length() && Character.isWhitespace(line.charAt(position))) { + position++; + } + } + + private int skipWhitespace(int from) { + while (from < line.length() && Character.isWhitespace(line.charAt(from))) { + from++; + } + return from; + } + + private void expect(char expected) { + if (position >= line.length() || line.charAt(position) != expected) { + throw new IllegalArgumentException( + "Expected '" + expected + "' in Prometheus sample at line " + lineNumber); + } + position++; + } + + private void expectEnd() { + if (position != line.length()) { + throw new IllegalArgumentException( + "Unexpected Prometheus sample content at line " + lineNumber); + } + } + } +} diff --git a/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/ScheduledExecutorUtil.java b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/ScheduledExecutorUtil.java new file mode 100644 index 0000000..fbbd6d9 --- /dev/null +++ b/metric-scrape/src/main/java/org/apache/iotdb/metricscrape/ScheduledExecutorUtil.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +final class ScheduledExecutorUtil { + + private static final Logger LOGGER = LoggerFactory.getLogger(ScheduledExecutorUtil.class); + + private ScheduledExecutorUtil() {} + + @SuppressWarnings("unsafeThreadSchedule") + static ScheduledFuture<?> safelyScheduleWithFixedDelay( + ScheduledExecutorService executor, + Runnable command, + long initialDelay, + long delay, + TimeUnit unit) { + return executor.scheduleWithFixedDelay( + () -> { + try { + command.run(); + } catch (Throwable t) { + LOGGER.error("Scheduled metric scrape task failed.", t); + } + }, + initialDelay, + delay, + unit); + } +} diff --git a/metric-scrape/src/test/java/org/apache/iotdb/metricscrape/MetricScrapeConfigLoaderTest.java b/metric-scrape/src/test/java/org/apache/iotdb/metricscrape/MetricScrapeConfigLoaderTest.java new file mode 100644 index 0000000..648c5fc --- /dev/null +++ b/metric-scrape/src/test/java/org/apache/iotdb/metricscrape/MetricScrapeConfigLoaderTest.java @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import org.junit.Test; + +import java.nio.file.Paths; +import java.time.Duration; + +import static org.junit.Assert.assertEquals; + +public class MetricScrapeConfigLoaderTest { + + @Test + public void loadExampleConfig() throws Exception { + MetricScrapeConfig config = MetricScrapeConfigLoader.load(Paths.get("conf/metric-scrape.yml")); + + assertEquals(Duration.ofSeconds(15), config.getGlobal().getScrapeInterval()); + assertEquals(Duration.ofSeconds(10), config.getGlobal().getScrapeTimeout()); + assertEquals("metrics", config.getGlobal().getDatabaseName()); + assertEquals(3, config.getScrapeConfigs().size()); + assertEquals("iotdbModel", config.getScrapeConfigs().get(0).getJobName()); + assertEquals("/metrics", config.getScrapeConfigs().get(0).getMetricsPath()); + } +} diff --git a/metric-scrape/src/test/java/org/apache/iotdb/metricscrape/PrometheusTextParserTest.java b/metric-scrape/src/test/java/org/apache/iotdb/metricscrape/PrometheusTextParserTest.java new file mode 100644 index 0000000..3677f94 --- /dev/null +++ b/metric-scrape/src/test/java/org/apache/iotdb/metricscrape/PrometheusTextParserTest.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.iotdb.metricscrape; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class PrometheusTextParserTest { + + @Test + public void parseSummarySamplesIntoHelpFamily() { + String text = + "# HELP request_duration_seconds Request duration.\n" + + "# TYPE request_duration_seconds summary\n" + + "request_duration_seconds_sum{method=\"query\",status=\"ok\"} 10.5\n" + + "request_duration_seconds_count{method=\"query\",status=\"ok\"} 3 1000\n"; + + List<PrometheusSample> samples = new PrometheusTextParser().parse(text, 123); + + assertEquals(2, samples.size()); + assertEquals("request_duration_seconds", samples.get(0).getMetricFamilyName()); + assertEquals("request_duration_seconds_sum", samples.get(0).getMetricName()); + assertEquals(10.5, samples.get(0).getValue(), 0.0); + assertEquals(123, samples.get(0).getTimestamp()); + assertEquals(1000, samples.get(1).getTimestamp()); + } + + @Test + public void parseLabelValueContainingQueryText() { + String text = "statement_cost{type=\"select * from root where a=\\\"b\\\", x=1\"} 1.0"; + + List<PrometheusSample> samples = new PrometheusTextParser().parse(text, 123); + + assertEquals(1, samples.size()); + assertEquals("select * from root where a=\"b\", x=1", samples.get(0).getLabels().get("type")); + } + + @Test + public void parseUnescapedQuoteInNestedLabelValueWithoutGhostLabels() { + String text = "m{type=\"Filter{predicate=\"x\", op=\"AND\"}\",quantile=\"0.5\"} 1"; + + List<PrometheusSample> samples = new PrometheusTextParser().parse(text, 123); + + assertEquals(1, samples.size()); + assertEquals("Filter{predicate=\"x\", op=\"AND\"}", samples.get(0).getLabels().get("type")); + assertEquals("0.5", samples.get(0).getLabels().get("quantile")); + assertFalse(samples.get(0).getLabels().containsKey("op")); + } + + @Test + public void onlyRouteKnownFamilyComponentSuffixesToHelpFamily() { + String text = + "# HELP http_requests Total requests.\n" + + "http_requests 5\n" + + "http_requests_duration_seconds 0.3\n" + + "http_requests_sum 7\n"; + + List<PrometheusSample> samples = new PrometheusTextParser().parse(text, 123); + + assertEquals("http_requests", samples.get(0).getMetricFamilyName()); + assertEquals("http_requests_duration_seconds", samples.get(1).getMetricFamilyName()); + assertEquals("http_requests", samples.get(2).getMetricFamilyName()); + } + + @Test + public void parsePrometheusFloatTokens() { + List<PrometheusSample> samples = + new PrometheusTextParser().parse("a inf\nb -inf\nc nan\nd 1_000\n", 123); + + assertTrue(Double.isInfinite(samples.get(0).getValue())); + assertTrue(Double.isInfinite(samples.get(1).getValue())); + assertTrue(Double.isNaN(samples.get(2).getValue())); + assertEquals(1000.0, samples.get(3).getValue(), 0.0); + } + + @Test + public void rejectJavaSpecificFloatSuffixes() { + assertThrows( + IllegalArgumentException.class, () -> new PrometheusTextParser().parse("a 1d\n", 123)); + assertThrows( + IllegalArgumentException.class, () -> new PrometheusTextParser().parse("a 2f\n", 123)); + } + + @Test + public void rejectUnknownLabelEscapes() { + assertThrows( + IllegalArgumentException.class, + () -> new PrometheusTextParser().parse("a{label=\"\\t\"} 1\n", 123)); + } +} diff --git a/pom.xml b/pom.xml index ca88f39..4de8c34 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ <module>connectors</module> <module>distributions</module> <module>iotdb-collector</module> + <module>metric-scrape</module> <module>mybatis-generator</module> </modules> <properties>
