Skip to content

Commit 629fb52

Browse files
authored
feat: introduce HYPERDX_OTEL_EXPORTER_TABLES_TTL (ClickStack OTel collector) (#1720)
- Users can configure table TTLs via `HYPERDX_OTEL_EXPORTER_TABLES_TTL`, which defaults to 720h. - Add TTL to metric tables Ref: HDX-3365
1 parent 5c895ff commit 629fb52

12 files changed

Lines changed: 871 additions & 81 deletions

File tree

‎.changeset/hungry-carpets-rule.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/otel-collector": patch
3+
---
4+
5+
feat: introduce HYPERDX_OTEL_EXPORTER_TABLES_TTL to support custom TTL configuration

‎.github/workflows/main.yml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ jobs:
7070
run: make ci-build
7171
- name: Run integration tests
7272
run: make ci-int
73+
otel-unit-test:
74+
timeout-minutes: 8
75+
runs-on: ubuntu-24.04
76+
steps:
77+
- name: Checkout
78+
uses: actions/checkout@v4
79+
- name: Get changed files
80+
id: changed-files
81+
uses: tj-actions/changed-files@v46
82+
with:
83+
files: |
84+
packages/otel-collector/**
85+
- name: Setup Go
86+
if: steps.changed-files.outputs.any_changed == 'true'
87+
uses: actions/setup-go@v5
88+
with:
89+
go-version-file: packages/otel-collector/go.mod
90+
cache-dependency-path: packages/otel-collector/go.sum
91+
- name: Run unit tests
92+
if: steps.changed-files.outputs.any_changed == 'true'
93+
working-directory: ./packages/otel-collector
94+
run: go test ./...
7395
otel-smoke-test:
7496
timeout-minutes: 8
7597
runs-on: ubuntu-24.04

‎docker-compose.dev.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ services:
3535
CUSTOM_OTELCOL_CONFIG_FILE: '/etc/otelcol-contrib/custom.config.yaml'
3636
# Uncomment to enable stdout logging for the OTel collector
3737
OTEL_SUPERVISOR_LOGS: 'true'
38+
HYPERDX_OTEL_EXPORTER_TABLES_TTL: '24h'
3839
volumes:
3940
- ./docker/otel-collector/config.yaml:/etc/otelcol-contrib/config.yaml
4041
- ./docker/otel-collector/supervisor_docker.yaml.tmpl:/etc/otel/supervisor.yaml.tmpl

‎docker/otel-collector/schema/seed/00002_otel_logs.sql‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,6 @@ ENGINE = MergeTree
3838
PARTITION BY toDate(TimestampTime)
3939
PRIMARY KEY (ServiceName, TimestampTime)
4040
ORDER BY (ServiceName, TimestampTime, Timestamp)
41-
TTL TimestampTime + toIntervalDay(30)
41+
TTL TimestampTime + ${TABLES_TTL}
4242
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
4343

‎docker/otel-collector/schema/seed/00003_otel_metrics.sql‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_gauge
3131
)
3232
ENGINE = MergeTree
3333
PARTITION BY toDate(TimeUnix)
34-
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix));
34+
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
35+
TTL toDateTime(TimeUnix) + ${TABLES_TTL}
36+
SETTINGS ttl_only_drop_parts = 1;
3537

3638
-- +goose StatementBegin
3739
CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_sum
@@ -68,7 +70,9 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_sum
6870
)
6971
ENGINE = MergeTree
7072
PARTITION BY toDate(TimeUnix)
71-
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix));
73+
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
74+
TTL toDateTime(TimeUnix) + ${TABLES_TTL}
75+
SETTINGS ttl_only_drop_parts = 1;
7276
-- +goose StatementEnd
7377

7478
-- +goose StatementBegin
@@ -110,7 +114,9 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_histogram
110114
)
111115
ENGINE = MergeTree
112116
PARTITION BY toDate(TimeUnix)
113-
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix));
117+
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
118+
TTL toDateTime(TimeUnix) + ${TABLES_TTL}
119+
SETTINGS ttl_only_drop_parts = 1;
114120
-- +goose StatementEnd
115121

116122
-- +goose StatementBegin
@@ -156,7 +162,9 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_exponential_histogram
156162
)
157163
ENGINE = MergeTree
158164
PARTITION BY toDate(TimeUnix)
159-
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix));
165+
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
166+
TTL toDateTime(TimeUnix) + ${TABLES_TTL}
167+
SETTINGS ttl_only_drop_parts = 1;
160168
-- +goose StatementEnd
161169

162170
-- +goose StatementBegin
@@ -190,6 +198,8 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_summary
190198
)
191199
ENGINE = MergeTree
192200
PARTITION BY toDate(TimeUnix)
193-
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix));
201+
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
202+
TTL toDateTime(TimeUnix) + ${TABLES_TTL}
203+
SETTINGS ttl_only_drop_parts = 1;
194204
-- +goose StatementEnd
195205

‎docker/otel-collector/schema/seed/00004_hyperdx_sessions.sql‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,6 @@ ENGINE = MergeTree
3030
PARTITION BY toDate(TimestampTime)
3131
PRIMARY KEY (ServiceName, TimestampTime)
3232
ORDER BY (ServiceName, TimestampTime, Timestamp)
33-
TTL TimestampTime + toIntervalDay(30)
33+
TTL TimestampTime + ${TABLES_TTL}
3434
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
3535

‎docker/otel-collector/schema/seed/00005_otel_traces.sql‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_traces
3636
ENGINE = MergeTree
3737
PARTITION BY toDate(Timestamp)
3838
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
39-
TTL toDate(Timestamp) + toIntervalDay(30)
39+
TTL toDate(Timestamp) + ${TABLES_TTL}
4040
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
4141

‎packages/api/src/opamp/controllers/opampController.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export const buildOtelCollectorConfig = (teams: ITeam[]): CollectorConfig => {
198198
database: '${env:HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE}',
199199
username: '${env:CLICKHOUSE_USER}',
200200
password: '${env:CLICKHOUSE_PASSWORD}',
201-
ttl: '720h',
201+
ttl: '${env:HYPERDX_OTEL_EXPORTER_TABLES_TTL:-720h}',
202202
logs_table_name: 'hyperdx_sessions',
203203
timeout: '5s',
204204
create_schema:
@@ -215,7 +215,7 @@ export const buildOtelCollectorConfig = (teams: ITeam[]): CollectorConfig => {
215215
database: '${env:HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE}',
216216
username: '${env:CLICKHOUSE_USER}',
217217
password: '${env:CLICKHOUSE_PASSWORD}',
218-
ttl: '720h',
218+
ttl: '${env:HYPERDX_OTEL_EXPORTER_TABLES_TTL:-720h}',
219219
timeout: '5s',
220220
create_schema:
221221
'${env:HYPERDX_OTEL_EXPORTER_CREATE_LEGACY_SCHEMA:-false}',

‎packages/otel-collector/cmd/migrate/main.go‎

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"os"
1616
"path/filepath"
1717
"sort"
18+
"strconv"
1819
"strings"
1920
"time"
2021

@@ -31,6 +32,9 @@ type Config struct {
3132
Password string
3233
Database string
3334

35+
// Table TTL (Go duration string, e.g. "720h")
36+
TablesTTL string
37+
3438
// TLS settings
3539
TLSCAFile string
3640
TLSCertFile string
@@ -50,6 +54,7 @@ func loadConfig() (*Config, error) {
5054
User: getEnv("CLICKHOUSE_USER", "default"),
5155
Password: getEnv("CLICKHOUSE_PASSWORD", ""),
5256
Database: getEnv("HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE", "default"),
57+
TablesTTL: getEnv("HYPERDX_OTEL_EXPORTER_TABLES_TTL", "720h"),
5358
TLSCAFile: getEnv("CLICKHOUSE_TLS_CA_FILE", ""),
5459
TLSCertFile: getEnv("CLICKHOUSE_TLS_CERT_FILE", ""),
5560
TLSKeyFile: getEnv("CLICKHOUSE_TLS_KEY_FILE", ""),
@@ -215,9 +220,46 @@ func createClickHouseDB(cfg *Config) (*sql.DB, error) {
215220
return db, nil
216221
}
217222

223+
// parseTTLDuration parses a duration string that supports days ("30d") in
224+
// addition to the standard Go duration format ("720h", "90m", "3600s").
225+
func parseTTLDuration(s string) (time.Duration, error) {
226+
// Handle "d" suffix (days) which Go's time.ParseDuration doesn't support
227+
if strings.HasSuffix(s, "d") {
228+
days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
229+
if err != nil {
230+
return 0, fmt.Errorf("invalid duration %q: %w", s, err)
231+
}
232+
return time.Duration(days) * 24 * time.Hour, nil
233+
}
234+
return time.ParseDuration(s)
235+
}
236+
237+
// ttlToClickHouseInterval converts a duration string (e.g. "30d", "720h",
238+
// "90m") to a ClickHouse interval expression, following the same approach as
239+
// the upstream otel-collector-contrib ClickHouse exporter's GenerateTTLExpr.
240+
func ttlToClickHouseInterval(ttl string) (string, error) {
241+
d, err := parseTTLDuration(ttl)
242+
if err != nil {
243+
return "", fmt.Errorf("invalid TTL duration %q: %w", ttl, err)
244+
}
245+
if d <= 0 {
246+
return "", fmt.Errorf("TTL must be positive, got %q", ttl)
247+
}
248+
switch {
249+
case d%(24*time.Hour) == 0:
250+
return fmt.Sprintf("toIntervalDay(%d)", d/(24*time.Hour)), nil
251+
case d%time.Hour == 0:
252+
return fmt.Sprintf("toIntervalHour(%d)", d/time.Hour), nil
253+
case d%time.Minute == 0:
254+
return fmt.Sprintf("toIntervalMinute(%d)", d/time.Minute), nil
255+
default:
256+
return fmt.Sprintf("toIntervalSecond(%d)", d/time.Second), nil
257+
}
258+
}
259+
218260
// processSchemaDir creates a temporary directory with SQL files that have
219-
// the ${DATABASE} macro replaced with the actual database name
220-
func processSchemaDir(schemaDir, database string) (string, error) {
261+
// the ${DATABASE} and ${TABLES_TTL} macros replaced with actual values
262+
func processSchemaDir(schemaDir, database, tablesTTLExpr string) (string, error) {
221263
tempDir, err := os.MkdirTemp("", "schema-*")
222264
if err != nil {
223265
return "", fmt.Errorf("failed to create temp directory: %w", err)
@@ -247,8 +289,9 @@ func processSchemaDir(schemaDir, database string) (string, error) {
247289
return fmt.Errorf("failed to read file %s: %w", path, err)
248290
}
249291

250-
// Replace ${DATABASE} macro with actual database name
292+
// Replace macros with actual values
251293
processedContent := strings.ReplaceAll(string(content), "${DATABASE}", database)
294+
processedContent = strings.ReplaceAll(processedContent, "${TABLES_TTL}", tablesTTLExpr)
252295

253296
// Write processed content to temp directory
254297
if err := os.WriteFile(destPath, []byte(processedContent), 0644); err != nil {
@@ -345,9 +388,16 @@ func main() {
345388
}
346389
log.Println("Successfully connected to ClickHouse")
347390

348-
// Process schema directory (replace ${DATABASE} macro)
391+
// Parse tables TTL
392+
tablesTTLExpr, err := ttlToClickHouseInterval(cfg.TablesTTL)
393+
if err != nil {
394+
log.Fatalf("Invalid HYPERDX_OTEL_EXPORTER_TABLES_TTL: %v", err)
395+
}
396+
log.Printf("Tables TTL: %s (%s)", cfg.TablesTTL, tablesTTLExpr)
397+
398+
// Process schema directory (replace ${DATABASE} and ${TABLES_TTL} macros)
349399
log.Printf("Preparing SQL files with database: %s", cfg.Database)
350-
tempDir, err := processSchemaDir(cfg.SchemaDir, cfg.Database)
400+
tempDir, err := processSchemaDir(cfg.SchemaDir, cfg.Database, tablesTTLExpr)
351401
if err != nil {
352402
log.Fatalf("Failed to process schema directory: %v", err)
353403
}

0 commit comments

Comments
 (0)