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
This commit is contained in:
Warren Lee 2026-02-10 17:00:38 +01:00 committed by GitHub
parent 5c895ff34a
commit 629fb52edc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 871 additions and 81 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/otel-collector": patch
---
feat: introduce HYPERDX_OTEL_EXPORTER_TABLES_TTL to support custom TTL configuration

View file

@ -70,6 +70,28 @@ jobs:
run: make ci-build
- name: Run integration tests
run: make ci-int
otel-unit-test:
timeout-minutes: 8
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v46
with:
files: |
packages/otel-collector/**
- name: Setup Go
if: steps.changed-files.outputs.any_changed == 'true'
uses: actions/setup-go@v5
with:
go-version-file: packages/otel-collector/go.mod
cache-dependency-path: packages/otel-collector/go.sum
- name: Run unit tests
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./packages/otel-collector
run: go test ./...
otel-smoke-test:
timeout-minutes: 8
runs-on: ubuntu-24.04

View file

@ -35,6 +35,7 @@ services:
CUSTOM_OTELCOL_CONFIG_FILE: '/etc/otelcol-contrib/custom.config.yaml'
# Uncomment to enable stdout logging for the OTel collector
OTEL_SUPERVISOR_LOGS: 'true'
HYPERDX_OTEL_EXPORTER_TABLES_TTL: '24h'
volumes:
- ./docker/otel-collector/config.yaml:/etc/otelcol-contrib/config.yaml
- ./docker/otel-collector/supervisor_docker.yaml.tmpl:/etc/otel/supervisor.yaml.tmpl

View file

@ -38,6 +38,6 @@ ENGINE = MergeTree
PARTITION BY toDate(TimestampTime)
PRIMARY KEY (ServiceName, TimestampTime)
ORDER BY (ServiceName, TimestampTime, Timestamp)
TTL TimestampTime + toIntervalDay(30)
TTL TimestampTime + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

View file

@ -31,7 +31,9 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_gauge
)
ENGINE = MergeTree
PARTITION BY toDate(TimeUnix)
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix));
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
TTL toDateTime(TimeUnix) + ${TABLES_TTL}
SETTINGS ttl_only_drop_parts = 1;
-- +goose StatementBegin
CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_sum
@ -68,7 +70,9 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_sum
)
ENGINE = MergeTree
PARTITION BY toDate(TimeUnix)
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix));
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
TTL toDateTime(TimeUnix) + ${TABLES_TTL}
SETTINGS ttl_only_drop_parts = 1;
-- +goose StatementEnd
-- +goose StatementBegin
@ -110,7 +114,9 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_histogram
)
ENGINE = MergeTree
PARTITION BY toDate(TimeUnix)
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix));
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
TTL toDateTime(TimeUnix) + ${TABLES_TTL}
SETTINGS ttl_only_drop_parts = 1;
-- +goose StatementEnd
-- +goose StatementBegin
@ -156,7 +162,9 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_exponential_histogram
)
ENGINE = MergeTree
PARTITION BY toDate(TimeUnix)
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix));
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
TTL toDateTime(TimeUnix) + ${TABLES_TTL}
SETTINGS ttl_only_drop_parts = 1;
-- +goose StatementEnd
-- +goose StatementBegin
@ -190,6 +198,8 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_metrics_summary
)
ENGINE = MergeTree
PARTITION BY toDate(TimeUnix)
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix));
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
TTL toDateTime(TimeUnix) + ${TABLES_TTL}
SETTINGS ttl_only_drop_parts = 1;
-- +goose StatementEnd

View file

@ -30,6 +30,6 @@ ENGINE = MergeTree
PARTITION BY toDate(TimestampTime)
PRIMARY KEY (ServiceName, TimestampTime)
ORDER BY (ServiceName, TimestampTime, Timestamp)
TTL TimestampTime + toIntervalDay(30)
TTL TimestampTime + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

View file

@ -36,6 +36,6 @@ CREATE TABLE IF NOT EXISTS ${DATABASE}.otel_traces
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + toIntervalDay(30)
TTL toDate(Timestamp) + ${TABLES_TTL}
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

View file

@ -198,7 +198,7 @@ export const buildOtelCollectorConfig = (teams: ITeam[]): CollectorConfig => {
database: '${env:HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE}',
username: '${env:CLICKHOUSE_USER}',
password: '${env:CLICKHOUSE_PASSWORD}',
ttl: '720h',
ttl: '${env:HYPERDX_OTEL_EXPORTER_TABLES_TTL:-720h}',
logs_table_name: 'hyperdx_sessions',
timeout: '5s',
create_schema:
@ -215,7 +215,7 @@ export const buildOtelCollectorConfig = (teams: ITeam[]): CollectorConfig => {
database: '${env:HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE}',
username: '${env:CLICKHOUSE_USER}',
password: '${env:CLICKHOUSE_PASSWORD}',
ttl: '720h',
ttl: '${env:HYPERDX_OTEL_EXPORTER_TABLES_TTL:-720h}',
timeout: '5s',
create_schema:
'${env:HYPERDX_OTEL_EXPORTER_CREATE_LEGACY_SCHEMA:-false}',

View file

@ -15,6 +15,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@ -31,6 +32,9 @@ type Config struct {
Password string
Database string
// Table TTL (Go duration string, e.g. "720h")
TablesTTL string
// TLS settings
TLSCAFile string
TLSCertFile string
@ -50,6 +54,7 @@ func loadConfig() (*Config, error) {
User: getEnv("CLICKHOUSE_USER", "default"),
Password: getEnv("CLICKHOUSE_PASSWORD", ""),
Database: getEnv("HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE", "default"),
TablesTTL: getEnv("HYPERDX_OTEL_EXPORTER_TABLES_TTL", "720h"),
TLSCAFile: getEnv("CLICKHOUSE_TLS_CA_FILE", ""),
TLSCertFile: getEnv("CLICKHOUSE_TLS_CERT_FILE", ""),
TLSKeyFile: getEnv("CLICKHOUSE_TLS_KEY_FILE", ""),
@ -215,9 +220,46 @@ func createClickHouseDB(cfg *Config) (*sql.DB, error) {
return db, nil
}
// parseTTLDuration parses a duration string that supports days ("30d") in
// addition to the standard Go duration format ("720h", "90m", "3600s").
func parseTTLDuration(s string) (time.Duration, error) {
// Handle "d" suffix (days) which Go's time.ParseDuration doesn't support
if strings.HasSuffix(s, "d") {
days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
if err != nil {
return 0, fmt.Errorf("invalid duration %q: %w", s, err)
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(s)
}
// ttlToClickHouseInterval converts a duration string (e.g. "30d", "720h",
// "90m") to a ClickHouse interval expression, following the same approach as
// the upstream otel-collector-contrib ClickHouse exporter's GenerateTTLExpr.
func ttlToClickHouseInterval(ttl string) (string, error) {
d, err := parseTTLDuration(ttl)
if err != nil {
return "", fmt.Errorf("invalid TTL duration %q: %w", ttl, err)
}
if d <= 0 {
return "", fmt.Errorf("TTL must be positive, got %q", ttl)
}
switch {
case d%(24*time.Hour) == 0:
return fmt.Sprintf("toIntervalDay(%d)", d/(24*time.Hour)), nil
case d%time.Hour == 0:
return fmt.Sprintf("toIntervalHour(%d)", d/time.Hour), nil
case d%time.Minute == 0:
return fmt.Sprintf("toIntervalMinute(%d)", d/time.Minute), nil
default:
return fmt.Sprintf("toIntervalSecond(%d)", d/time.Second), nil
}
}
// processSchemaDir creates a temporary directory with SQL files that have
// the ${DATABASE} macro replaced with the actual database name
func processSchemaDir(schemaDir, database string) (string, error) {
// the ${DATABASE} and ${TABLES_TTL} macros replaced with actual values
func processSchemaDir(schemaDir, database, tablesTTLExpr string) (string, error) {
tempDir, err := os.MkdirTemp("", "schema-*")
if err != nil {
return "", fmt.Errorf("failed to create temp directory: %w", err)
@ -247,8 +289,9 @@ func processSchemaDir(schemaDir, database string) (string, error) {
return fmt.Errorf("failed to read file %s: %w", path, err)
}
// Replace ${DATABASE} macro with actual database name
// Replace macros with actual values
processedContent := strings.ReplaceAll(string(content), "${DATABASE}", database)
processedContent = strings.ReplaceAll(processedContent, "${TABLES_TTL}", tablesTTLExpr)
// Write processed content to temp directory
if err := os.WriteFile(destPath, []byte(processedContent), 0644); err != nil {
@ -345,9 +388,16 @@ func main() {
}
log.Println("Successfully connected to ClickHouse")
// Process schema directory (replace ${DATABASE} macro)
// Parse tables TTL
tablesTTLExpr, err := ttlToClickHouseInterval(cfg.TablesTTL)
if err != nil {
log.Fatalf("Invalid HYPERDX_OTEL_EXPORTER_TABLES_TTL: %v", err)
}
log.Printf("Tables TTL: %s (%s)", cfg.TablesTTL, tablesTTLExpr)
// Process schema directory (replace ${DATABASE} and ${TABLES_TTL} macros)
log.Printf("Preparing SQL files with database: %s", cfg.Database)
tempDir, err := processSchemaDir(cfg.SchemaDir, cfg.Database)
tempDir, err := processSchemaDir(cfg.SchemaDir, cfg.Database, tablesTTLExpr)
if err != nil {
log.Fatalf("Failed to process schema directory: %v", err)
}

View file

@ -0,0 +1,708 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// ---------------------------------------------------------------------------
// getEnv / getOrDefault
// ---------------------------------------------------------------------------
func TestGetEnv(t *testing.T) {
const key = "TEST_GETENV_KEY_MIGRATE"
// Returns default when env var is unset
got := getEnv(key, "fallback")
if got != "fallback" {
t.Errorf("getEnv unset: got %q, want %q", got, "fallback")
}
// Returns value when env var is set
t.Setenv(key, "custom")
got = getEnv(key, "fallback")
if got != "custom" {
t.Errorf("getEnv set: got %q, want %q", got, "custom")
}
// Treats empty string the same as unset (returns default)
t.Setenv(key, "")
got = getEnv(key, "fallback")
if got != "fallback" {
t.Errorf("getEnv empty: got %q, want %q", got, "fallback")
}
}
func TestGetOrDefault(t *testing.T) {
if got := getOrDefault("", "def"); got != "def" {
t.Errorf("getOrDefault empty: got %q, want %q", got, "def")
}
if got := getOrDefault("val", "def"); got != "val" {
t.Errorf("getOrDefault non-empty: got %q, want %q", got, "val")
}
}
// ---------------------------------------------------------------------------
// loadConfig
// ---------------------------------------------------------------------------
func TestLoadConfig(t *testing.T) {
// Save and restore os.Args
origArgs := os.Args
t.Cleanup(func() { os.Args = origArgs })
t.Run("missing schema dir arg", func(t *testing.T) {
os.Args = []string{"migrate"}
_, err := loadConfig()
if err == nil {
t.Fatal("expected error when no schema dir argument")
}
if !strings.Contains(err.Error(), "usage:") {
t.Errorf("expected usage message, got: %v", err)
}
})
t.Run("nonexistent schema dir", func(t *testing.T) {
os.Args = []string{"migrate", "/nonexistent/path/schema"}
_, err := loadConfig()
if err == nil {
t.Fatal("expected error for nonexistent schema dir")
}
if !strings.Contains(err.Error(), "does not exist") {
t.Errorf("expected 'does not exist' error, got: %v", err)
}
})
t.Run("valid config with defaults", func(t *testing.T) {
dir := t.TempDir()
os.Args = []string{"migrate", dir}
cfg, err := loadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Endpoint != "tcp://localhost:9000" {
t.Errorf("Endpoint: got %q, want default", cfg.Endpoint)
}
if cfg.User != "default" {
t.Errorf("User: got %q, want %q", cfg.User, "default")
}
if cfg.Database != "default" {
t.Errorf("Database: got %q, want %q", cfg.Database, "default")
}
if cfg.TablesTTL != "720h" {
t.Errorf("TablesTTL: got %q, want %q", cfg.TablesTTL, "720h")
}
if cfg.SchemaDir != dir {
t.Errorf("SchemaDir: got %q, want %q", cfg.SchemaDir, dir)
}
if cfg.TLSInsecureSkipVerify {
t.Error("TLSInsecureSkipVerify should default to false")
}
})
t.Run("env var overrides", func(t *testing.T) {
dir := t.TempDir()
os.Args = []string{"migrate", dir}
t.Setenv("CLICKHOUSE_ENDPOINT", "https://ch.example.com:8443")
t.Setenv("CLICKHOUSE_USER", "admin")
t.Setenv("CLICKHOUSE_PASSWORD", "secret")
t.Setenv("HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE", "mydb")
t.Setenv("HYPERDX_OTEL_EXPORTER_TABLES_TTL", "7d")
t.Setenv("CLICKHOUSE_TLS_INSECURE_SKIP_VERIFY", "true")
cfg, err := loadConfig()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Endpoint != "https://ch.example.com:8443" {
t.Errorf("Endpoint: got %q", cfg.Endpoint)
}
if cfg.User != "admin" {
t.Errorf("User: got %q", cfg.User)
}
if cfg.Password != "secret" {
t.Errorf("Password: got %q", cfg.Password)
}
if cfg.Database != "mydb" {
t.Errorf("Database: got %q", cfg.Database)
}
if cfg.TablesTTL != "7d" {
t.Errorf("TablesTTL: got %q", cfg.TablesTTL)
}
if !cfg.TLSInsecureSkipVerify {
t.Error("TLSInsecureSkipVerify should be true")
}
})
}
// ---------------------------------------------------------------------------
// parseEndpoint
// ---------------------------------------------------------------------------
func TestParseEndpoint(t *testing.T) {
tests := []struct {
name string
input string
protocol string
host string
port string
secure bool
wantErr bool
}{
{
name: "tcp with port",
input: "tcp://localhost:9000",
protocol: "native", host: "localhost", port: "9000", secure: false,
},
{
name: "tcp default port",
input: "tcp://clickhouse",
protocol: "native", host: "clickhouse", port: "9000", secure: false,
},
{
name: "http with port",
input: "http://clickhouse:8123",
protocol: "http", host: "clickhouse", port: "8123", secure: false,
},
{
name: "http default port",
input: "http://clickhouse",
protocol: "http", host: "clickhouse", port: "8123", secure: false,
},
{
name: "https with port",
input: "https://clickhouse:9443",
protocol: "http", host: "clickhouse", port: "9443", secure: true,
},
{
name: "https default port",
input: "https://clickhouse.example.com",
protocol: "http", host: "clickhouse.example.com", port: "8443", secure: true,
},
{
name: "no scheme defaults to tcp",
input: "clickhouse:9000",
protocol: "native", host: "clickhouse", port: "9000", secure: false,
},
{
name: "unsupported scheme",
input: "ftp://clickhouse:21",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
proto, host, port, secure, err := parseEndpoint(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("parseEndpoint(%q) expected error", tt.input)
}
return
}
if err != nil {
t.Fatalf("parseEndpoint(%q) unexpected error: %v", tt.input, err)
}
if proto != tt.protocol {
t.Errorf("protocol: got %q, want %q", proto, tt.protocol)
}
if host != tt.host {
t.Errorf("host: got %q, want %q", host, tt.host)
}
if port != tt.port {
t.Errorf("port: got %q, want %q", port, tt.port)
}
if secure != tt.secure {
t.Errorf("secure: got %v, want %v", secure, tt.secure)
}
})
}
}
// ---------------------------------------------------------------------------
// parseTLSConfig
// ---------------------------------------------------------------------------
// helper: generate a self-signed CA + leaf cert/key pair on disk.
func generateTestCerts(t *testing.T, dir string) (caFile, certFile, keyFile string) {
t.Helper()
// CA key & cert
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
caTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test-ca"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
caFile = filepath.Join(dir, "ca.pem")
if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), 0644); err != nil {
t.Fatal(err)
}
// Leaf key & cert (signed by CA)
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
leafTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "test-leaf"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caTemplate, &leafKey.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
certFile = filepath.Join(dir, "cert.pem")
if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}), 0644); err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalECPrivateKey(leafKey)
if err != nil {
t.Fatal(err)
}
keyFile = filepath.Join(dir, "key.pem")
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0644); err != nil {
t.Fatal(err)
}
return caFile, certFile, keyFile
}
func TestParseTLSConfig(t *testing.T) {
dir := t.TempDir()
caFile, certFile, keyFile := generateTestCerts(t, dir)
t.Run("empty config", func(t *testing.T) {
cfg := &Config{}
tlsCfg, err := parseTLSConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tlsCfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be false")
}
if tlsCfg.ServerName != "" {
t.Errorf("ServerName should be empty, got %q", tlsCfg.ServerName)
}
if tlsCfg.RootCAs != nil {
t.Error("RootCAs should be nil")
}
if len(tlsCfg.Certificates) != 0 {
t.Error("Certificates should be empty")
}
})
t.Run("insecure skip verify", func(t *testing.T) {
cfg := &Config{TLSInsecureSkipVerify: true}
tlsCfg, err := parseTLSConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !tlsCfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be true")
}
})
t.Run("server name override", func(t *testing.T) {
cfg := &Config{TLSServerNameOverride: "custom.host"}
tlsCfg, err := parseTLSConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tlsCfg.ServerName != "custom.host" {
t.Errorf("ServerName: got %q, want %q", tlsCfg.ServerName, "custom.host")
}
})
t.Run("CA certificate", func(t *testing.T) {
cfg := &Config{TLSCAFile: caFile}
tlsCfg, err := parseTLSConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tlsCfg.RootCAs == nil {
t.Fatal("RootCAs should not be nil")
}
})
t.Run("client certificate", func(t *testing.T) {
cfg := &Config{TLSCertFile: certFile, TLSKeyFile: keyFile}
tlsCfg, err := parseTLSConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tlsCfg.Certificates) != 1 {
t.Errorf("expected 1 client cert, got %d", len(tlsCfg.Certificates))
}
})
t.Run("full TLS config", func(t *testing.T) {
cfg := &Config{
TLSCAFile: caFile,
TLSCertFile: certFile,
TLSKeyFile: keyFile,
TLSServerNameOverride: "ch.example.com",
TLSInsecureSkipVerify: true,
}
tlsCfg, err := parseTLSConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tlsCfg.RootCAs == nil {
t.Error("RootCAs should not be nil")
}
if len(tlsCfg.Certificates) != 1 {
t.Errorf("expected 1 client cert, got %d", len(tlsCfg.Certificates))
}
if tlsCfg.ServerName != "ch.example.com" {
t.Errorf("ServerName: got %q", tlsCfg.ServerName)
}
if !tlsCfg.InsecureSkipVerify {
t.Error("InsecureSkipVerify should be true")
}
})
t.Run("nonexistent CA file", func(t *testing.T) {
cfg := &Config{TLSCAFile: "/nonexistent/ca.pem"}
_, err := parseTLSConfig(cfg)
if err == nil {
t.Fatal("expected error for nonexistent CA file")
}
})
t.Run("invalid CA PEM", func(t *testing.T) {
badCA := filepath.Join(dir, "bad-ca.pem")
if err := os.WriteFile(badCA, []byte("not a certificate"), 0644); err != nil {
t.Fatal(err)
}
cfg := &Config{TLSCAFile: badCA}
_, err := parseTLSConfig(cfg)
if err == nil {
t.Fatal("expected error for invalid CA PEM")
}
})
t.Run("nonexistent client cert", func(t *testing.T) {
cfg := &Config{TLSCertFile: "/nonexistent/cert.pem", TLSKeyFile: keyFile}
_, err := parseTLSConfig(cfg)
if err == nil {
t.Fatal("expected error for nonexistent client cert")
}
})
t.Run("cert without key is ignored", func(t *testing.T) {
// Only TLSCertFile set, TLSKeyFile empty → should not load client cert
cfg := &Config{TLSCertFile: certFile}
tlsCfg, err := parseTLSConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tlsCfg.Certificates) != 0 {
t.Error("should not load client cert when key is missing")
}
})
}
// ---------------------------------------------------------------------------
// listSQLFiles
// ---------------------------------------------------------------------------
func TestListSQLFiles(t *testing.T) {
t.Run("mixed files", func(t *testing.T) {
dir := t.TempDir()
// Create SQL and non-SQL files
for _, name := range []string{"002_b.sql", "001_a.sql", "readme.md", "003_c.sql"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("--"), 0644); err != nil {
t.Fatal(err)
}
}
// Create a subdirectory (should be skipped)
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0755); err != nil {
t.Fatal(err)
}
files, err := listSQLFiles(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := []string{"001_a.sql", "002_b.sql", "003_c.sql"}
if len(files) != len(expected) {
t.Fatalf("got %d files, want %d: %v", len(files), len(expected), files)
}
for i, f := range files {
if f != expected[i] {
t.Errorf("files[%d] = %q, want %q", i, f, expected[i])
}
}
})
t.Run("empty dir", func(t *testing.T) {
dir := t.TempDir()
files, err := listSQLFiles(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 0 {
t.Errorf("expected no files, got %v", files)
}
})
t.Run("nonexistent dir", func(t *testing.T) {
_, err := listSQLFiles("/nonexistent/dir")
if err == nil {
t.Fatal("expected error for nonexistent dir")
}
})
}
// ---------------------------------------------------------------------------
// processSchemaDir (additional cases)
// ---------------------------------------------------------------------------
func TestProcessSchemaDir_Subdirectories(t *testing.T) {
schemaDir := t.TempDir()
subDir := filepath.Join(schemaDir, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(
filepath.Join(subDir, "001_nested.sql"),
[]byte("CREATE DATABASE IF NOT EXISTS ${DATABASE};"),
0644,
); err != nil {
t.Fatal(err)
}
tempDir, err := processSchemaDir(schemaDir, "testdb", "toIntervalHour(48)")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
got, err := os.ReadFile(filepath.Join(tempDir, "sub", "001_nested.sql"))
if err != nil {
t.Fatalf("nested file not found: %v", err)
}
expected := "CREATE DATABASE IF NOT EXISTS testdb;"
if string(got) != expected {
t.Errorf("got %q, want %q", string(got), expected)
}
}
func TestProcessSchemaDir_MultipleReplacements(t *testing.T) {
schemaDir := t.TempDir()
content := "${DATABASE}.t1 TTL ${TABLES_TTL} ${DATABASE}.t2 TTL ${TABLES_TTL}"
if err := os.WriteFile(filepath.Join(schemaDir, "001.sql"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
tempDir, err := processSchemaDir(schemaDir, "db", "toIntervalDay(7)")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
got, err := os.ReadFile(filepath.Join(tempDir, "001.sql"))
if err != nil {
t.Fatal(err)
}
expected := "db.t1 TTL toIntervalDay(7) db.t2 TTL toIntervalDay(7)"
if string(got) != expected {
t.Errorf("got %q, want %q", string(got), expected)
}
}
func TestProcessSchemaDir_NoMacros(t *testing.T) {
schemaDir := t.TempDir()
content := "SELECT 1;"
if err := os.WriteFile(filepath.Join(schemaDir, "001.sql"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
tempDir, err := processSchemaDir(schemaDir, "db", "toIntervalDay(30)")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
got, err := os.ReadFile(filepath.Join(tempDir, "001.sql"))
if err != nil {
t.Fatal(err)
}
if string(got) != content {
t.Errorf("content should be unchanged: got %q", string(got))
}
}
func TestProcessSchemaDir_NonexistentDir(t *testing.T) {
_, err := processSchemaDir("/nonexistent/schema", "db", "toIntervalDay(30)")
if err == nil {
t.Fatal("expected error for nonexistent schema dir")
}
}
// ---------------------------------------------------------------------------
// parseTTLDuration / ttlToClickHouseInterval (existing tests below)
// ---------------------------------------------------------------------------
func TestParseTTLDuration(t *testing.T) {
tests := []struct {
input string
expected time.Duration
wantErr bool
}{
// Days
{"1d", 24 * time.Hour, false},
{"30d", 30 * 24 * time.Hour, false},
{"365d", 365 * 24 * time.Hour, false},
// Standard Go durations
{"720h", 720 * time.Hour, false},
{"48h", 48 * time.Hour, false},
{"90m", 90 * time.Minute, false},
{"3600s", 3600 * time.Second, false},
{"1h30m", time.Hour + 30*time.Minute, false},
// Errors
{"", 0, true},
{"abc", 0, true},
{"d", 0, true},
{"12.5d", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parseTTLDuration(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("parseTTLDuration(%q) expected error, got %v", tt.input, got)
}
return
}
if err != nil {
t.Errorf("parseTTLDuration(%q) unexpected error: %v", tt.input, err)
return
}
if got != tt.expected {
t.Errorf("parseTTLDuration(%q) = %v, want %v", tt.input, got, tt.expected)
}
})
}
}
func TestTTLToClickHouseInterval(t *testing.T) {
tests := []struct {
input string
expected string
wantErr bool
}{
// Days - evenly divisible by 24h
{"30d", "toIntervalDay(30)", false},
{"1d", "toIntervalDay(1)", false},
{"365d", "toIntervalDay(365)", false},
{"720h", "toIntervalDay(30)", false},
{"48h", "toIntervalDay(2)", false},
{"24h", "toIntervalDay(1)", false},
// Hours - not evenly divisible by 24h
{"36h", "toIntervalHour(36)", false},
{"1h", "toIntervalHour(1)", false},
{"100h", "toIntervalHour(100)", false},
// Minutes
{"90m", "toIntervalMinute(90)", false},
{"30m", "toIntervalMinute(30)", false},
{"1h30m", "toIntervalMinute(90)", false},
// Seconds
{"90s", "toIntervalSecond(90)", false},
{"1m30s", "toIntervalSecond(90)", false},
// Errors
{"0s", "", true},
{"-1h", "", true},
{"abc", "", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ttlToClickHouseInterval(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("ttlToClickHouseInterval(%q) expected error, got %q", tt.input, got)
}
return
}
if err != nil {
t.Errorf("ttlToClickHouseInterval(%q) unexpected error: %v", tt.input, err)
return
}
if got != tt.expected {
t.Errorf("ttlToClickHouseInterval(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestProcessSchemaDir(t *testing.T) {
// Create a temp schema directory with a test SQL file
schemaDir := t.TempDir()
sqlContent := `CREATE TABLE IF NOT EXISTS ${DATABASE}.test_table
(col1 String)
ENGINE = MergeTree
ORDER BY col1
TTL toDateTime(col1) + ${TABLES_TTL}
SETTINGS ttl_only_drop_parts = 1;`
if err := os.WriteFile(filepath.Join(schemaDir, "001_test.sql"), []byte(sqlContent), 0644); err != nil {
t.Fatal(err)
}
tempDir, err := processSchemaDir(schemaDir, "mydb", "toIntervalDay(30)")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
got, err := os.ReadFile(filepath.Join(tempDir, "001_test.sql"))
if err != nil {
t.Fatal(err)
}
expected := `CREATE TABLE IF NOT EXISTS mydb.test_table
(col1 String)
ENGINE = MergeTree
ORDER BY col1
TTL toDateTime(col1) + toIntervalDay(30)
SETTINGS ttl_only_drop_parts = 1;`
if string(got) != expected {
t.Errorf("processSchemaDir output mismatch\ngot:\n%s\nwant:\n%s", string(got), expected)
}
}

View file

@ -3,28 +3,28 @@ module github.com/hyperdxio/hyperdx/packages/otel-collector
go 1.25
require (
github.com/ClickHouse/clickhouse-go/v2 v2.30.0
github.com/pressly/goose/v3 v3.24.1
github.com/ClickHouse/clickhouse-go/v2 v2.43.0
github.com/pressly/goose/v3 v3.26.0
)
require (
github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/ClickHouse/ch-go v0.71.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.7 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
go.opentelemetry.io/otel v1.26.0 // indirect
go.opentelemetry.io/otel/trace v1.26.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
)

View file

@ -1,9 +1,11 @@
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
github.com/ClickHouse/clickhouse-go/v2 v2.30.0/go.mod h1:i9ZQAojcayW3RsdCb3YR+n+wC2h65eJsZCscZ1Z1wyo=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE=
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -18,23 +20,21 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
@ -42,31 +42,28 @@ github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGA
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
@ -77,16 +74,20 @@ github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7Jul
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@ -98,16 +99,16 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -124,23 +125,16 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=