Repository: jaegertracing/jaeger

Issue:
[clickhouse] Add End-to-end Integration Test For ClickHouse
This is blocked on #7137.

## Goal
Add an end to end integration test for ClickHouse storage. This will require a docker-compose setup with the [clickhouse-server](https://hub.docker.com/r/clickhouse/clickhouse-server) Docker image.

Discussion:
@Harshal292004: I would like to work on this as soon as #7137 is done @Junnygram: can i work on this ?


Solution patch:
diff --git a/.github/workflows/ci-e2e-all.yml b/.github/workflows/ci-e2e-all.yml
index adf3e4f7..c24b5e5f 100644
--- a/.github/workflows/ci-e2e-all.yml
+++ b/.github/workflows/ci-e2e-all.yml
@@ -40,6 +40,9 @@ jobs:
   query:
     uses: ./.github/workflows/ci-e2e-query.yml
 
+  clickhouse:
+    uses: ./.github/workflows/ci-e2e-clickhouse.yml
+
   upload_pr_number:
     name: Save and Upload PR Number as Artifact
     runs-on: ubuntu-latest
diff --git a/.github/workflows/ci-e2e-clickhouse.yml b/.github/workflows/ci-e2e-clickhouse.yml
new file mode 100644
index 00000000..0c66d0f8
--- /dev/null
+++ b/.github/workflows/ci-e2e-clickhouse.yml
@@ -0,0 +1,44 @@
+name: CIT ClickHouse
+
+on:
+  workflow_call:
+
+concurrency:
+  group: cit-clickhouse-${{ github.workflow }}-${{ (github.event.pull_request && github.event.pull_request.number) || github.ref || github.run_id }}
+  cancel-in-progress: true
+
+# See https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions
+permissions:  # added using https://github.com/step-security/secure-workflows
+  contents: read
+
+jobs:
+  clickhouse:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Harden Runner
+      uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
+      with:
+        egress-policy: audit # TODO: change to 'egress-policy: block' after couple of runs
+
+    - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+
+    - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
+      with:
+        go-version: 1.25.x
+
+    - name: Run ClickHouse integration tests
+      id: test-execution
+      run: bash scripts/e2e/clickhouse.sh
+     
+    - uses: ./.github/actions/verify-metrics-snapshot
+      with:
+        snapshot: metrics_snapshot_clickhouse
+        artifact_key: metrics_snapshot_clickhouse
+
+    - name: Upload coverage to codecov
+      uses: ./.github/actions/upload-codecov
+      with:
+        files: cover.out
+        flags: clickhouse
+    
+    
diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml
index 5c1da562..b6373cbc 100644
--- a/.github/workflows/ci-release.yml
+++ b/.github/workflows/ci-release.yml
@@ -37,7 +37,7 @@ jobs:
       contents: write
       deployments: write
     if: github.repository == 'jaegertracing/jaeger'
-    runs-on: ubuntu-latest
+    runs-on: jaeger-linux-amd64-32core-1200GB_SSD
 
     steps:
     - name: Clean up some disk space
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 737dacb4..0c6116c6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,7 +21,55 @@ copy from UI changelog
 
 </details>
 
-next release v1.73.0 / v2.10.0 (2025-09-02)
+v1.74.0 / v2.11.0 (2025-10-01)
+-------------------------------
+
+### Backend Changes
+
+#### 🐞 Bug fixes, Minor Improvements
+
+* Make enabletracing param work correctly in jaeger-v2 query extension ([@Frapschen](https://github.com/Frapschen) in [#7226](https://github.com/jaegertracing/jaeger/pull/7226))
+
+#### 🚧 Experimental Features
+
+* [clickhouse] implement factory with minimal configuration ([@mahadzaryab1](https://github.com/mahadzaryab1) in [#7518](https://github.com/jaegertracing/jaeger/pull/7518))
+* [clickhouse] implement writer for clickhouse storage ([@mahadzaryab1](https://github.com/mahadzaryab1) in [#7514](https://github.com/jaegertracing/jaeger/pull/7514))
+* [clickhouse] add attributes for event in clickhouse storage ([@mahadzaryab1](https://github.com/mahadzaryab1) in [#7512](https://github.com/jaegertracing/jaeger/pull/7512))
+* [clickhouse] add column for storing complex attributes ([@mahadzaryab1](https://github.com/mahadzaryab1) in [#7510](https://github.com/jaegertracing/jaeger/pull/7510))
+* [clickhouse] add attributes to span table for clickhouse storage ([@mahadzaryab1](https://github.com/mahadzaryab1) in [#7503](https://github.com/jaegertracing/jaeger/pull/7503))
+
+#### ⚙️ Refactoring
+
+* Move clickhouse queries to sql files with embed directive ([@mahadzaryab1](https://github.com/mahadzaryab1) in [#7523](https://github.com/jaegertracing/jaeger/pull/7523))
+* Use maps.copy for cleaner map handling ([@quantpoet](https://github.com/quantpoet) in [#7513](https://github.com/jaegertracing/jaeger/pull/7513))
+
+
+### 📊 UI Changes
+
+#### 🐞 Bug fixes, Minor Improvements
+
+* Replace dependency react-window ([@Parship999](https://github.com/Parship999) in [#3070](https://github.com/jaegertracing/jaeger-ui/pull/3070))
+* Fix the flaky test in tracepage/index.test.js ([@Parship999](https://github.com/Parship999) in [#3089](https://github.com/jaegertracing/jaeger-ui/pull/3089))
+* Fix top bar tab order ([@mdwyer6](https://github.com/mdwyer6) in [#3067](https://github.com/jaegertracing/jaeger-ui/pull/3067))
+* Expand the logs automatically ([@Parship999](https://github.com/Parship999) in [#3054](https://github.com/jaegertracing/jaeger-ui/pull/3054))
+
+#### ⚙️ Refactoring
+
+* Convert tracediff component from class to functional component ([@Parship999](https://github.com/Parship999) in [#3099](https://github.com/jaegertracing/jaeger-ui/pull/3099))
+* Remove the history instance from the app component ([@Parship999](https://github.com/Parship999) in [#3100](https://github.com/jaegertracing/jaeger-ui/pull/3100))
+* Update to modern jsx transform ([@Parship999](https://github.com/Parship999) in [#3097](https://github.com/jaegertracing/jaeger-ui/pull/3097))
+* Fix some eslint warnings ([@Parship999](https://github.com/Parship999) in [#3096](https://github.com/jaegertracing/jaeger-ui/pull/3096))
+* Convert servicesview/index to functional component ([@Parship999](https://github.com/Parship999) in [#3004](https://github.com/jaegertracing/jaeger-ui/pull/3004))
+* Convert filteredlist/index.tsx from class to functional component ([@Parship999](https://github.com/Parship999) in [#3083](https://github.com/jaegertracing/jaeger-ui/pull/3083))
+* Fix some lint warnings ([@Parship999](https://github.com/Parship999) in [#3090](https://github.com/jaegertracing/jaeger-ui/pull/3090))
+* Convert searchresults/diffselection to functional component and improved testcases ([@JeevaRamanathan](https://github.com/JeevaRamanathan) in [#3076](https://github.com/jaegertracing/jaeger-ui/pull/3076))
+* Convert tracediff/tracediffheader {cohorttable, tracediffheader} to functional component ([@JeevaRamanathan](https://github.com/JeevaRamanathan) in [#3082](https://github.com/jaegertracing/jaeger-ui/pull/3082))
+* Convert seachresults{resultitem, resultitemtitle} to functional components ([@JeevaRamanathan](https://github.com/JeevaRamanathan) in [#3071](https://github.com/jaegertracing/jaeger-ui/pull/3071))
+* Tighten tracearchive type to more strictly enforce correct state ([@tklever](https://github.com/tklever) in [#623](https://github.com/jaegertracing/jaeger-ui/pull/623))
+
+
+
+v1.73.0 / v2.10.0 (2025-09-02)
 -------------------------------
 
 ### Backend Changes
diff --git a/RELEASE.md b/RELEASE.md
index 7865cb05..29e3a6e0 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -14,8 +14,8 @@ Create an issue with the checklist for the release by running `bash scripts/rele
 
 <!-- BEGIN_CHECKLIST -->
 
-1. Create a PR "Prepare release 1.x.x / 2.x.x" against main or maintenance branch ([example](https://github.com/jaegertracing/jaeger/pull/6826)) by updating CHANGELOG.md to include:
-    * A new section with the header `1.x.x / 2.x.x (YYYY-MM-DD)` (copy the template at the top)
+1. Create a PR "Prepare release v1.x.x / v2.x.x" against main or maintenance branch ([example](https://github.com/jaegertracing/jaeger/pull/6826)) by updating CHANGELOG.md to include:
+    * A new section with the header `v1.x.x / v2.x.x (YYYY-MM-DD)` (copy the template at the top)
     * A curated list of notable changes and links to PRs. Do not simply dump git log, select the changes that affect the users.
       To obtain the list of all changes run `make changelog`.
     * The section can be split into sub-section if necessary, e.g. UI Changes, Backend Changes, Bug Fixes, etc.
@@ -26,7 +26,7 @@ Create an issue with the checklist for the release by running `bash scripts/rele
         pushd jaeger-ui
         git checkout main
         git pull
-        git checkout {new_ui_version} # e.g. v1.5.0
+        git checkout v1.x.x  # use the new version
         popd
         ```
       * If there are only dependency bumps, indicate this with "Dependencies upgrades only" ([example](https://github.com/jaegertracing/jaeger-ui/pull/2431/files)).
@@ -37,9 +37,9 @@ Create an issue with the checklist for the release by running `bash scripts/rele
     ```
     git checkout main
     git pull
-    git tag v1... -s  # use the new version
-    git tag v2... -s  # use the new version
-    git push upstream v1... v2...
+    git tag v1.x.x -s  # use the new version
+    git tag v2.x.x -s  # use the new version
+    git push upstream v1.x.x v2.x.x
     ```
 3. Create a release on Github:
     * Automated:
@@ -55,7 +55,7 @@ Create an issue with the checklist for the release by running `bash scripts/rele
 ## Manual release
 
     * Manual:
-       * Title "Release 1.x.x / 2.x.x"
+       * Title "Prepare Release v1.x.x / v2.x.x"
        * Tag `v1.x.x` (note the `v` prefix) and choose appropriate branch (usually `main`)
        * Copy the new CHANGELOG.md section into the release notes
        * Extra: GitHub has a button "generate release notes". Those are not formatted as we want,
@@ -88,9 +88,9 @@ Here are the release managers for future versions with the tentative release dat
 
 | Version | Release Manager | Tentative release date |
 |---------|-----------------|------------------------|
-| 2.11.0  | @albertteoh     | 1 October   2025       |
 | 2.12.0  | @pavolloffay    | 5 November  2025       |
 | 2.13.0  | @joe-elliott    | 3 December  2025       |
 | 2.14.0  | @mahadzaryab1   | 7 January   2026       |
 | 2.15.0  | @jkowall        | 4 February  2026       |
 | 2.16.0  | @yurishkuro     | 5 March     2026       |
+| 2.17.0  | @albertteoh     | 1 April     2026       |
diff --git a/cmd/jaeger/config-clickhouse.yaml b/cmd/jaeger/config-clickhouse.yaml
new file mode 100644
index 00000000..32dea895
--- /dev/null
+++ b/cmd/jaeger/config-clickhouse.yaml
@@ -0,0 +1,58 @@
+service:
+  extensions: [jaeger_storage, jaeger_query, healthcheckv2]
+  pipelines:
+    traces:
+      receivers: [otlp]
+      processors: [batch]
+      exporters: [jaeger_storage_exporter]
+  telemetry:
+    resource:
+      service.name: jaeger
+    metrics:
+      level: detailed
+      readers:
+        - pull:
+            exporter:
+              prometheus:
+                host: 0.0.0.0
+                port: 8888
+    logs:
+      level: debug
+    # TODO Initialize telemetry tracer once OTEL released new feature.
+    # https://github.com/open-telemetry/opentelemetry-collector/issues/10663
+
+extensions:
+  healthcheckv2:
+    use_v2: true
+    http:
+
+  jaeger_query:
+    storage:
+      traces: some-storage
+    ui:
+      config_file: ./cmd/jaeger/config-ui.json
+  jaeger_storage:
+    backends:
+      some-storage:
+        clickhouse:
+          addresses:
+            - localhost:9000
+          database: jaeger
+          auth:
+            basic:
+              username: default
+              password: password
+          create_schema: true
+
+receivers:
+  otlp:
+    protocols:
+      grpc:
+      http:
+
+processors:
+  batch:
+
+exporters:
+  jaeger_storage_exporter:
+    trace_storage: some-storage
diff --git a/cmd/jaeger/internal/extension/jaegerstorage/config.go b/cmd/jaeger/internal/extension/jaegerstorage/config.go
index c0b420b5..1766bc0d 100644
--- a/cmd/jaeger/internal/extension/jaegerstorage/config.go
+++ b/cmd/jaeger/internal/extension/jaegerstorage/config.go
@@ -20,6 +20,7 @@ import (
 	"github.com/jaegertracing/jaeger/internal/storage/v1/cassandra"
 	es "github.com/jaegertracing/jaeger/internal/storage/v1/elasticsearch"
 	"github.com/jaegertracing/jaeger/internal/storage/v1/memory"
+	"github.com/jaegertracing/jaeger/internal/storage/v2/clickhouse"
 	"github.com/jaegertracing/jaeger/internal/storage/v2/grpc"
 )
 
@@ -41,12 +42,13 @@ type Config struct {
 
 // TraceBackend contains configuration for a single trace storage backend.
 type TraceBackend struct {
-	Memory        *memory.Configuration `mapstructure:"memory"`
-	Badger        *badger.Config        `mapstructure:"badger"`
-	GRPC          *grpc.Config          `mapstructure:"grpc"`
-	Cassandra     *cassandra.Options    `mapstructure:"cassandra"`
-	Elasticsearch *esCfg.Configuration  `mapstructure:"elasticsearch"`
-	Opensearch    *esCfg.Configuration  `mapstructure:"opensearch"`
+	Memory        *memory.Configuration     `mapstructure:"memory"`
+	Badger        *badger.Config            `mapstructure:"badger"`
+	GRPC          *grpc.Config              `mapstructure:"grpc"`
+	Cassandra     *cassandra.Options        `mapstructure:"cassandra"`
+	Elasticsearch *esCfg.Configuration      `mapstructure:"elasticsearch"`
+	Opensearch    *esCfg.Configuration      `mapstructure:"opensearch"`
+	ClickHouse    *clickhouse.Configuration `mapstructure:"clickhouse"`
 }
 
 // MetricBackend contains configuration for a single metric storage backend.
diff --git a/cmd/jaeger/internal/extension/jaegerstorage/extension.go b/cmd/jaeger/internal/extension/jaegerstorage/extension.go
index 2fb994aa..601b7d40 100644
--- a/cmd/jaeger/internal/extension/jaegerstorage/extension.go
+++ b/cmd/jaeger/internal/extension/jaegerstorage/extension.go
@@ -19,6 +19,7 @@ import (
 	"github.com/jaegertracing/jaeger/internal/storage/v2/api/tracestore"
 	"github.com/jaegertracing/jaeger/internal/storage/v2/badger"
 	"github.com/jaegertracing/jaeger/internal/storage/v2/cassandra"
+	"github.com/jaegertracing/jaeger/internal/storage/v2/clickhouse"
 	es "github.com/jaegertracing/jaeger/internal/storage/v2/elasticsearch"
 	"github.com/jaegertracing/jaeger/internal/storage/v2/grpc"
 	"github.com/jaegertracing/jaeger/internal/storage/v2/memory"
@@ -195,6 +196,14 @@ func (s *storageExt) Start(ctx context.Context, host component.Host) error {
 				*cfg.Opensearch,
 				osTelset,
 			)
+		case cfg.ClickHouse != nil:
+			chTelset := telset
+			chTelset.Metrics = scopedMetricsFactory(storageName, "clickhouse", "tracestore")
+			factory, err = clickhouse.NewFactory(
+				ctx,
+				*cfg.ClickHouse,
+				chTelset,
+			)
 		default:
 			// default case
 		}
diff --git a/docker-compose/clickhouse/docker-compose.yml b/docker-compose/clickhouse/docker-compose.yml
new file mode 100644
index 00000000..3f59c81f
--- /dev/null
+++ b/docker-compose/clickhouse/docker-compose.yml
@@ -0,0 +1,16 @@
+services:
+  clickhouse:
+    image: clickhouse/clickhouse-server:25.9.2
+    container_name: clickhouse
+    environment:
+      - CLICKHOUSE_USER=default
+      - CLICKHOUSE_PASSWORD=password
+      - CLICKHOUSE_DB=jaeger
+    ports:
+      - "8123:8123"
+      - "9000:9000"
+    healthcheck:
+      test: ["CMD", "clickhouse-client", "--query=SELECT 1"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
diff --git a/internal/jptrace/spankind.go b/internal/jptrace/spankind.go
index 21ef0d7c..03a8081b 100644
--- a/internal/jptrace/spankind.go
+++ b/internal/jptrace/spankind.go
@@ -3,7 +3,11 @@
 
 package jptrace
 
-import "go.opentelemetry.io/collector/pdata/ptrace"
+import (
+	"strings"
+
+	"go.opentelemetry.io/collector/pdata/ptrace"
+)
 
 func StringToSpanKind(sk string) ptrace.SpanKind {
 	switch sk {
@@ -23,3 +27,10 @@ func StringToSpanKind(sk string) ptrace.SpanKind {
 		return ptrace.SpanKindUnspecified
 	}
 }
+
+func SpanKindToString(sk ptrace.SpanKind) string {
+	if sk == ptrace.SpanKindUnspecified {
+		return ""
+	}
+	return strings.ToLower(sk.String())
+}
diff --git a/internal/storage/v2/clickhouse/tracestore/config.go b/internal/storage/v2/clickhouse/config.go
similarity index 83%
rename from internal/storage/v2/clickhouse/tracestore/config.go
rename to internal/storage/v2/clickhouse/config.go
index f3d01916..7dd380db 100644
--- a/internal/storage/v2/clickhouse/tracestore/config.go
+++ b/internal/storage/v2/clickhouse/config.go
@@ -1,7 +1,7 @@
 // Copyright (c) 2025 The Jaeger Authors.
 // SPDX-License-Identifier: Apache-2.0
 
-package tracestore
+package clickhouse
 
 import (
 	"time"
@@ -11,7 +11,7 @@ import (
 	"go.opentelemetry.io/collector/config/configoptional"
 )
 
-type Config struct {
+type Configuration struct {
 	// Protocol is the protocol to use to connect to ClickHouse.
 	// Supported values are "native" and "http". Default is "native".
 	Protocol string `mapstructure:"protocol" valid:"in(native|http),optional"`
@@ -23,6 +23,8 @@ type Config struct {
 	Auth Authentication `mapstructure:"auth"`
 	// DialTimeout is the timeout for establishing a connection to ClickHouse.
 	DialTimeout time.Duration `mapstructure:"dial_timeout"`
+	// CreateSchema, if set to true, will create the ClickHouse schema if it does not exist.
+	CreateSchema bool `mapstructure:"create_schema"`
 	// TODO: add more settings
 }
 
@@ -31,7 +33,7 @@ type Authentication struct {
 	// TODO: add JWT
 }
 
-func (cfg *Config) Validate() error {
+func (cfg *Configuration) Validate() error {
 	_, err := govalidator.ValidateStruct(cfg)
 	return err
 }
diff --git a/internal/storage/v2/clickhouse/depstore/reader.go b/internal/storage/v2/clickhouse/depstore/reader.go
new file mode 100644
index 00000000..d3749d5a
--- /dev/null
+++ b/internal/storage/v2/clickhouse/depstore/reader.go
@@ -0,0 +1,23 @@
+// Copyright (c) 2025 The Jaeger Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package depstore
+
+import (
+	"context"
+
+	"github.com/jaegertracing/jaeger-idl/model/v1"
+	"github.com/jaegertracing/jaeger/internal/storage/v2/api/depstore"
+)
+
+var _ depstore.Reader = (*Reader)(nil)
+
+type Reader struct{}
+
+func NewDependencyReader() *Reader {
+	return &Reader{}
+}
+
+func (*Reader) GetDependencies(context.Context, depstore.QueryParameters) ([]model.DependencyLink, error) {
+	panic("not implemented")
+}
diff --git a/internal/storage/v2/clickhouse/factory.go b/internal/storage/v2/clickhouse/factory.go
new file mode 100644
index 00000000..959cc0b6
--- /dev/null
+++ b/internal/storage/v2/clickhouse/factory.go
@@ -0,0 +1,127 @@
+// Copyright (c) 2025 The Jaeger Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package clickhouse
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+
+	"github.com/ClickHouse/clickhouse-go/v2"
+	"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
+
+	"github.com/jaegertracing/jaeger/internal/storage/v1"
+	"github.com/jaegertracing/jaeger/internal/storage/v2/api/depstore"
+	"github.com/jaegertracing/jaeger/internal/storage/v2/api/tracestore"
+	chdepstore "github.com/jaegertracing/jaeger/internal/storage/v2/clickhouse/depstore"
+	"github.com/jaegertracing/jaeger/internal/storage/v2/clickhouse/sql"
+	chtracestore "github.com/jaegertracing/jaeger/internal/storage/v2/clickhouse/tracestore"
+	"github.com/jaegertracing/jaeger/internal/telemetry"
+)
+
+var (
+	_ io.Closer          = (*Factory)(nil)
+	_ depstore.Factory   = (*Factory)(nil)
+	_ tracestore.Factory = (*Factory)(nil)
+	_ storage.Purger     = (*Factory)(nil)
+)
+
+type Factory struct {
+	config Configuration
+	telset telemetry.Settings
+	conn   driver.Conn
+}
+
+func NewFactory(ctx context.Context, cfg Configuration, telset telemetry.Settings) (*Factory, error) {
+	f := &Factory{
+		config: cfg,
+		telset: telset,
+	}
+	opts := &clickhouse.Options{
+		Protocol: getProtocol(f.config.Protocol),
+		Addr:     f.config.Addresses,
+		Auth: clickhouse.Auth{
+			Database: f.config.Database,
+		},
+		DialTimeout: f.config.DialTimeout,
+	}
+	basicAuth := f.config.Auth.Basic.Get()
+	if basicAuth != nil {
+		opts.Auth.Username = basicAuth.Username
+		opts.Auth.Password = string(basicAuth.Password)
+	}
+	conn, err := clickhouse.Open(opts)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create ClickHouse connection: %w", err)
+	}
+	err = conn.Ping(ctx)
+	if err != nil {
+		return nil, errors.Join(
+			fmt.Errorf("failed to ping ClickHouse: %w", err),
+			conn.Close(),
+		)
+	}
+	if f.config.CreateSchema {
+		schemas := []struct {
+			name  string
+			query string
+		}{
+			{"spans table", sql.CreateSpansTable},
+			{"services table", sql.CreateServicesTable},
+			{"services materialized view", sql.CreateServicesMaterializedView},
+			{"operations table", sql.CreateOperationsTable},
+			{"operations materialized view", sql.CreateOperationsMaterializedView},
+		}
+
+		for _, schema := range schemas {
+			if err = conn.Exec(ctx, schema.query); err != nil {
+				return nil, errors.Join(fmt.Errorf("failed to create %s: %w", schema.name, err), conn.Close())
+			}
+		}
+	}
+	f.conn = conn
+	return f, nil
+}
+
+func (f *Factory) CreateTraceReader() (tracestore.Reader, error) {
+	return chtracestore.NewReader(f.conn), nil
+}
+
+func (f *Factory) CreateTraceWriter() (tracestore.Writer, error) {
+	return chtracestore.NewWriter(f.conn), nil
+}
+
+func (*Factory) CreateDependencyReader() (depstore.Reader, error) {
+	return chdepstore.NewDependencyReader(), nil
+}
+
+func (f *Factory) Close() error {
+	return f.conn.Close()
+}
+
+func (f *Factory) Purge(ctx context.Context) error {
+	tables := []struct {
+		name  string
+		query string
+	}{
+		{"spans", sql.TruncateSpans},
+		{"services", sql.TruncateServices},
+		{"operations", sql.TruncateOperations},
+	}
+
+	for _, table := range tables {
+		if err := f.conn.Exec(ctx, table.query); err != nil {
+			return fmt.Errorf("failed to purge %s: %w", table.name, err)
+		}
+	}
+	return nil
+}
+
+func getProtocol(protocol string) clickhouse.Protocol {
+	if protocol == "http" {
+		return clickhouse.HTTP
+	}
+	return clickhouse.Native
+}
diff --git a/internal/storage/v2/clickhouse/sql/create_operations_mv.sql b/internal/storage/v2/clickhouse/sql/create_operations_mv.sql
new file mode 100644
index 00000000..cfb93896
--- /dev/null
+++ b/internal/storage/v2/clickhouse/sql/create_operations_mv.sql
@@ -0,0 +1,7 @@
+CREATE MATERIALIZED VIEW IF NOT EXISTS operations_mv TO operations AS
+SELECT
+    name,
+    kind AS span_kind,
+    service_name
+FROM
+    spans;
\ No newline at end of file
diff --git a/internal/storage/v2/clickhouse/sql/create_operations_table.sql b/internal/storage/v2/clickhouse/sql/create_operations_table.sql
new file mode 100644
index 00000000..e23c7806
--- /dev/null
+++ b/internal/storage/v2/clickhouse/sql/create_operations_table.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS
+    operations (
+        service_name String,
+        name String,
+        span_kind String
+    ) ENGINE = ReplacingMergeTree
+ORDER BY
+    (service_name, name, span_kind);
\ No newline at end of file
diff --git a/internal/storage/v2/clickhouse/sql/create_services_mv.sql b/internal/storage/v2/clickhouse/sql/create_services_mv.sql
new file mode 100644
index 00000000..8f96f16b
--- /dev/null
+++ b/internal/storage/v2/clickhouse/sql/create_services_mv.sql
@@ -0,0 +1,7 @@
+CREATE MATERIALIZED VIEW IF NOT EXISTS services_mv TO services AS
+SELECT
+    service_name AS name
+FROM
+    spans
+GROUP BY
+    service_name
\ No newline at end of file
diff --git a/internal/storage/v2/clickhouse/sql/create_services_table.sql b/internal/storage/v2/clickhouse/sql/create_services_table.sql
new file mode 100644
index 00000000..41b9f0e4
--- /dev/null
+++ b/internal/storage/v2/clickhouse/sql/create_services_table.sql
@@ -0,0 +1,4 @@
+CREATE TABLE
+    IF NOT EXISTS services (name String) ENGINE = ReplacingMergeTree
+ORDER BY
+    (name);
\ No newline at end of file
diff --git a/internal/storage/v2/clickhouse/sql/create_schema.sql b/internal/storage/v2/clickhouse/sql/create_spans_table.sql
similarity index 67%
rename from internal/storage/v2/clickhouse/sql/create_schema.sql
rename to internal/storage/v2/clickhouse/sql/create_spans_table.sql
index af16f378..63d4b81d 100644
--- a/internal/storage/v2/clickhouse/sql/create_schema.sql
+++ b/internal/storage/v2/clickhouse/sql/create_spans_table.sql
@@ -32,28 +32,4 @@ CREATE TABLE
         service_name String,
         scope_name String,
         scope_version String
-    ) ENGINE = MergeTree PRIMARY KEY (trace_id);
-
-CREATE TABLE
-    IF NOT EXISTS services (name String) ENGINE = AggregatingMergeTree PRIMARY KEY (name);
-
-CREATE MATERIALIZED VIEW IF NOT EXISTS services_mv TO services AS
-SELECT
-    service_name AS name
-FROM
-    spans
-GROUP BY
-    service_name;
-
-CREATE TABLE
-    IF NOT EXISTS operations (name String, span_kind String) ENGINE = AggregatingMergeTree PRIMARY KEY (name, span_kind);
-
-CREATE MATERIALIZED VIEW IF NOT EXISTS operations_mv TO operations AS
-SELECT
-    name,
-    kind AS span_kind
-FROM
-    spans
-GROUP BY
-    name,
-    span_kind;
\ No newline at end of file
+    ) ENGINE = MergeTree PRIMARY KEY (trace_id)
\ No newline at end of file
diff --git a/internal/storage/v2/clickhouse/sql/queries.go b/internal/storage/v2/clickhouse/sql/queries.go
index 7be4e9db..87bd6df9 100644
--- a/internal/storage/v2/clickhouse/sql/queries.go
+++ b/internal/storage/v2/clickhouse/sql/queries.go
@@ -80,7 +80,7 @@ FROM
 `
 
 const SelectOperationsAllKinds = `
-SELECT
+SELECT DISTINCT
     name,
     span_kind
 FROM
@@ -90,7 +90,7 @@ WHERE
 `
 
 const SelectOperationsByKind = `
-SELECT
+SELECT DISTINCT
     name,
     span_kind
 FROM
@@ -100,5 +100,23 @@ WHERE
     AND span_kind = ?
 `
 
-//go:embed create_schema.sql
-var CreateSchema string
+const TruncateSpans = `TRUNCATE TABLE spans`
+
+const TruncateServices = `TRUNCATE TABLE services`
+
+const TruncateOperations = `TRUNCATE TABLE operations`
+
+//go:embed create_spans_table.sql
+var CreateSpansTable string
+
+//go:embed create_services_table.sql
+var CreateServicesTable string
+
+//go:embed create_services_mv.sql
+var CreateServicesMaterializedView string
+
+//go:embed create_operations_table.sql
+var CreateOperationsTable string
+
+//go:embed create_operations_mv.sql
+var CreateOperationsMaterializedView string
diff --git a/internal/storage/v2/clickhouse/tracestore/factory.go b/internal/storage/v2/clickhouse/tracestore/factory.go
deleted file mode 100644
index dedada08..00000000
--- a/internal/storage/v2/clickhouse/tracestore/factory.go
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright (c) 2025 The Jaeger Authors.
-// SPDX-License-Identifier: Apache-2.0
-
-package tracestore
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"io"
-
-	"github.com/ClickHouse/clickhouse-go/v2"
-	"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
-
-	"github.com/jaegertracing/jaeger/internal/storage/v2/api/tracestore"
-	"github.com/jaegertracing/jaeger/internal/telemetry"
-)
-
-var (
-	_ io.Closer          = (*Factory)(nil)
-	_ tracestore.Factory = (*Factory)(nil)
-)
-
-type Factory struct {
-	config Config
-	telset telemetry.Settings
-	conn   driver.Conn
-}
-
-func NewFactory(ctx context.Context, cfg Config, telset telemetry.Settings) (*Factory, error) {
-	f := &Factory{
-		config: cfg,
-		telset: telset,
-	}
-	opts := &clickhouse.Options{
-		Protocol: getProtocol(f.config.Protocol),
-		Addr:     f.config.Addresses,
-		Auth: clickhouse.Auth{
-			Database: f.config.Database,
-		},
-		DialTimeout: f.config.DialTimeout,
-	}
-	basicAuth := f.config.Auth.Basic.Get()
-	if basicAuth != nil {
-		opts.Auth.Username = basicAuth.Username
-		opts.Auth.Password = string(basicAuth.Password)
-	}
-	conn, err := clickhouse.Open(opts)
-	if err != nil {
-		return nil, fmt.Errorf("failed to create ClickHouse connection: %w", err)
-	}
-	err = conn.Ping(ctx)
-	if err != nil {
-		return nil, errors.Join(
-			fmt.Errorf("failed to ping ClickHouse: %w", err),
-			conn.Close(),
-		)
-	}
-	f.conn = conn
-	return f, nil
-}
-
-func (f *Factory) CreateTraceReader() (tracestore.Reader, error) {
-	return NewReader(f.conn), nil
-}
-
-func (f *Factory) CreateTraceWriter() (tracestore.Writer, error) {
-	return NewWriter(f.conn), nil
-}
-
-func (f *Factory) Close() error {
-	return f.conn.Close()
-}
-
-func getProtocol(protocol string) clickhouse.Protocol {
-	if protocol == "http" {
-		return clickhouse.HTTP
-	}
-	return clickhouse.Native
-}
diff --git a/internal/storage/v2/clickhouse/tracestore/reader.go b/internal/storage/v2/clickhouse/tracestore/reader.go
index a79403fb..d9cde3d6 100644
--- a/internal/storage/v2/clickhouse/tracestore/reader.go
+++ b/internal/storage/v2/clickhouse/tracestore/reader.go
@@ -113,10 +113,11 @@ func (r *Reader) GetOperations(
 		if err := rows.ScanStruct(&operation); err != nil {
 			return nil, fmt.Errorf("failed to scan row: %w", err)
 		}
-		operations = append(operations, tracestore.Operation{
+		o := tracestore.Operation{
 			Name:     operation.Name,
 			SpanKind: operation.SpanKind,
-		})
+		}
+		operations = append(operations, o)
 	}
 	return operations, nil
 }
diff --git a/internal/storage/v2/clickhouse/tracestore/writer.go b/internal/storage/v2/clickhouse/tracestore/writer.go
index 07e8f993..e917482b 100644
--- a/internal/storage/v2/clickhouse/tracestore/writer.go
+++ b/internal/storage/v2/clickhouse/tracestore/writer.go
@@ -10,6 +10,7 @@ import (
 	"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
 	"go.opentelemetry.io/collector/pdata/ptrace"
 
+	"github.com/jaegertracing/jaeger/internal/jptrace"
 	"github.com/jaegertracing/jaeger/internal/storage/v2/clickhouse/sql"
 	"github.com/jaegertracing/jaeger/internal/telemetry/otelsemconv"
 )
@@ -44,7 +45,7 @@ func (w *Writer) WriteTraces(ctx context.Context, td ptrace.Traces) error {
 					span.TraceState().AsRaw(),
 					span.ParentSpanID().String(),
 					span.Name(),
-					span.Kind().String(),
+					jptrace.SpanKindToString(span.Kind()),
 					span.StartTimestamp().AsTime(),
 					span.Status().Code().String(),
 					span.Status().Message(),
diff --git a/jaeger-ui b/jaeger-ui
index b375fa5d..4606e0e7 160000
--- a/jaeger-ui
+++ b/jaeger-ui
@@ -1 +1 @@
-Subproject commit b375fa5d52ea248c11c48cfca202652103fff430
+Subproject commit 4606e0e7aba353224c0a7d1d2588366da3993f24
diff --git a/scripts/e2e/clickhouse.sh b/scripts/e2e/clickhouse.sh
new file mode 100755
index 00000000..7a3d8428
--- /dev/null
+++ b/scripts/e2e/clickhouse.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+
+# Copyright (c) 2025 The Jaeger Authors.
+# SPDX-License-Identifier: Apache-2.0
+
+set -euxf -o pipefail
+
+success="false"
+timeout=600
+end_time=$((SECONDS + timeout))
+compose_file="docker-compose/clickhouse/docker-compose.yml"
+container_name="clickhouse"
+
+setup_clickhouse() {
+    echo "Starting ClickHouse with $compose_file"
+    docker compose -f "$compose_file" up -d
+}
+
+healthcheck_clickhouse() {
+    local wait_seconds=10
+
+    while [ $SECONDS -lt $end_time ]; do
+        status=$(docker inspect -f '{{ .State.Health.Status }}' "${container_name}")
+        if [[ ${status} == "healthy" ]]; then
+            echo "✅ $container_name is healthy"
+            return 0
+        fi
+        echo "Waiting for $container_name to be healthy. Current status: $status"
+        sleep $wait_seconds
+    done
+
+    echo "❌ ERROR: $container_name did not become healthy in time"
+    exit 1
+}
+
+dump_logs() {
+    echo "::group::🚧 🚧 🚧 Clickhouse logs"
+    docker compose -f "${compose_file}" logs
+    echo "::endgroup::"
+}
+
+teardown_clickhouse() {
+    if [[ "$success" == "false" ]]; then
+        dump_logs "${compose_file}"
+    fi
+    docker compose -f "$compose_file" down
+}
+
+run_integration_test() {
+    setup_clickhouse
+    trap teardown_clickhouse EXIT
+    healthcheck_clickhouse
+    STORAGE=clickhouse make jaeger-v2-storage-integration-test
+    success="true"
+}
+
+main() {
+    echo "Executing ClickHouse integration tests"
+    run_integration_test
+}
+
+main
\ No newline at end of file
diff --git a/scripts/release/start.sh b/scripts/release/start.sh
index a3ab44a8..ce10e18b 100644
--- a/scripts/release/start.sh
+++ b/scripts/release/start.sh
@@ -8,11 +8,15 @@ set -euo pipefail
 
 dry_run=false
 
-while getopts "d" opt; do
+while getopts "dh" opt; do
     case "${opt}" in
         d)
             dry_run=true
             ;;
+        h)
+            echo "Usage: $0 [-d]"
+            exit 0
+            ;;
         *)
             echo "Usage: $0 [-d]"
             exit 1



Test patch:
diff --git a/cmd/jaeger/internal/extension/jaegerstorage/extension_test.go b/cmd/jaeger/internal/extension/jaegerstorage/extension_test.go
index baf469f9..610f8521 100644
--- a/cmd/jaeger/internal/extension/jaegerstorage/extension_test.go
+++ b/cmd/jaeger/internal/extension/jaegerstorage/extension_test.go
@@ -29,6 +29,8 @@ import (
 	"github.com/jaegertracing/jaeger/internal/storage/v1/cassandra"
 	"github.com/jaegertracing/jaeger/internal/storage/v1/memory"
 	"github.com/jaegertracing/jaeger/internal/storage/v2/api/tracestore"
+	"github.com/jaegertracing/jaeger/internal/storage/v2/clickhouse"
+	"github.com/jaegertracing/jaeger/internal/storage/v2/clickhouse/clickhousetest"
 	"github.com/jaegertracing/jaeger/internal/storage/v2/grpc"
 )
 
@@ -483,6 +485,26 @@ func TestCassandraError(t *testing.T) {
 	require.ErrorContains(t, err, "Servers: non zero value required")
 }
 
+func TestClickHouse(t *testing.T) {
+	testServer := clickhousetest.NewServer(clickhousetest.FailureConfig{})
+	t.Cleanup(testServer.Close)
+	ext := makeStorageExtension(t, &Config{
+		TraceBackends: map[string]TraceBackend{
+			"foo": {
+				ClickHouse: &clickhouse.Configuration{
+					Protocol: "http",
+					Addresses: []string{
+						testServer.Listener.Addr().String(),
+					},
+				},
+			},
+		},
+	})
+	err := ext.Start(t.Context(), componenttest.NewNopHost())
+	require.NoError(t, err)
+	require.NoError(t, ext.Shutdown(t.Context()))
+}
+
 func noopTelemetrySettings() component.TelemetrySettings {
 	return component.TelemetrySettings{
 		Logger:         zap.L(),
diff --git a/cmd/jaeger/internal/integration/clickhouse_test.go b/cmd/jaeger/internal/integration/clickhouse_test.go
new file mode 100644
index 00000000..acf9481e
--- /dev/null
+++ b/cmd/jaeger/internal/integration/clickhouse_test.go
@@ -0,0 +1,25 @@
+// Copyright (c) 2025 The Jaeger Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package integration
+
+import (
+	"testing"
+
+	"github.com/jaegertracing/jaeger/internal/storage/integration"
+)
+
+func TestClickHouseStorage(t *testing.T) {
+	integration.SkipUnlessEnv(t, "clickhouse")
+	s := &E2EStorageIntegration{
+		ConfigFile: "../../config-clickhouse.yaml",
+		StorageIntegration: integration.StorageIntegration{
+			CleanUp: purge,
+			SkipList: []string{
+				"FindTraces",
+			},
+		},
+	}
+	s.e2eInitialize(t, "clickhouse")
+	s.RunSpanStoreTests(t)
+}
diff --git a/internal/jptrace/spankind_test.go b/internal/jptrace/spankind_test.go
index fa7c27f7..0d7da221 100644
--- a/internal/jptrace/spankind_test.go
+++ b/internal/jptrace/spankind_test.go
@@ -58,3 +58,40 @@ func TestStringToSpanKind(t *testing.T) {
 		})
 	}
 }
+
+func TestSpanKindToString(t *testing.T) {
+	tests := []struct {
+		kind ptrace.SpanKind
+		want string
+	}{
+		{
+			kind: ptrace.SpanKindUnspecified,
+			want: "",
+		},
+		{
+			kind: ptrace.SpanKindInternal,
+			want: "internal",
+		},
+		{
+			kind: ptrace.SpanKindServer,
+			want: "server",
+		},
+		{
+			kind: ptrace.SpanKindClient,
+			want: "client",
+		},
+		{
+			kind: ptrace.SpanKindProducer,
+			want: "producer",
+		},
+		{
+			kind: ptrace.SpanKindConsumer,
+			want: "consumer",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.want, func(t *testing.T) {
+			require.Equal(t, tt.want, SpanKindToString(tt.kind))
+		})
+	}
+}
diff --git a/internal/storage/v2/clickhouse/clickhousetest/package_test.go b/internal/storage/v2/clickhouse/clickhousetest/package_test.go
new file mode 100644
index 00000000..bfcde3a5
--- /dev/null
+++ b/internal/storage/v2/clickhouse/clickhousetest/package_test.go
@@ -0,0 +1,14 @@
+// Copyright (c) 2025 The Jaeger Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package clickhousetest
+
+import (
+	"testing"
+
+	"github.com/jaegertracing/jaeger/internal/testutils"
+)
+
+func TestMain(m *testing.M) {
+	testutils.VerifyGoLeaks(m)
+}
diff --git a/internal/storage/v2/clickhouse/clickhousetest/server.go b/internal/storage/v2/clickhouse/clickhousetest/server.go
new file mode 100644
index 00000000..29d4eec2
--- /dev/null
+++ b/internal/storage/v2/clickhouse/clickhousetest/server.go
@@ -0,0 +1,57 @@
+// Copyright (c) 2025 The Jaeger Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package clickhousetest
+
+import (
+	"io"
+	"net/http"
+	"net/http/httptest"
+
+	"github.com/ClickHouse/ch-go/proto"
+	"github.com/ClickHouse/clickhouse-go/v2"
+	chproto "github.com/ClickHouse/clickhouse-go/v2/lib/proto"
+)
+
+var (
+	PingQuery      = "SELECT 1"
+	HandshakeQuery = "SELECT displayName(), version(), revision(), timezone()"
+)
+
+// FailureConfig is a map of query body to error
+type FailureConfig map[string]error
+
+// NewServer creates a new HTTP test server that simulates a ClickHouse server.
+// It should only be used in tests.
+func NewServer(failures FailureConfig) *httptest.Server {
+	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, _ := io.ReadAll(r.Body)
+		query := string(body)
+
+		block := chproto.NewBlock()
+
+		if err, shouldFail := failures[query]; shouldFail {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		switch query {
+		case PingQuery:
+			block.AddColumn("1", "UInt8")
+			block.Append(uint8(1))
+		case HandshakeQuery:
+			block.AddColumn("displayName()", "String")
+			block.AddColumn("version()", "String")
+			block.AddColumn("revision()", "UInt32")
+			block.AddColumn("timezone()", "String")
+			block.Append("mock-server", "23.3.1", chproto.DBMS_MIN_REVISION_WITH_CUSTOM_SERIALIZATION, "UTC")
+		default:
+		}
+
+		var buf proto.Buffer
+		block.Encode(&buf, clickhouse.ClientTCPProtocolVersion)
+
+		w.Header().Set("Content-Type", "application/octet-stream")
+		w.Write(buf.Buf)
+	}))
+}
diff --git a/internal/storage/v2/clickhouse/tracestore/config_test.go b/internal/storage/v2/clickhouse/config_test.go
similarity index 87%
rename from internal/storage/v2/clickhouse/tracestore/config_test.go
rename to internal/storage/v2/clickhouse/config_test.go
index fa15fd0b..5d33d936 100644
--- a/internal/storage/v2/clickhouse/tracestore/config_test.go
+++ b/internal/storage/v2/clickhouse/config_test.go
@@ -1,7 +1,7 @@
 // Copyright (c) 2025 The Jaeger Authors.
 // SPDX-License-Identifier: Apache-2.0
 
-package tracestore
+package clickhouse
 
 import (
 	"testing"
@@ -12,12 +12,12 @@ import (
 func TestValidate(t *testing.T) {
 	tests := []struct {
 		name    string
-		cfg     Config
+		cfg     Configuration
 		wantErr bool
 	}{
 		{
 			name: "valid config with native protocol",
-			cfg: Config{
+			cfg: Configuration{
 				Protocol:  "native",
 				Addresses: []string{"localhost:9000"},
 			},
@@ -25,7 +25,7 @@ func TestValidate(t *testing.T) {
 		},
 		{
 			name: "valid config with http protocol",
-			cfg: Config{
+			cfg: Configuration{
 				Protocol:  "http",
 				Addresses: []string{"localhost:8123"},
 			},
@@ -33,14 +33,14 @@ func TestValidate(t *testing.T) {
 		},
 		{
 			name: "valid config with empty protocol",
-			cfg: Config{
+			cfg: Configuration{
 				Addresses: []string{"localhost:9000"},
 			},
 			wantErr: false,
 		},
 		{
 			name: "valid config with multiple addresses",
-			cfg: Config{
+			cfg: Configuration{
 				Protocol:  "native",
 				Addresses: []string{"localhost:9000", "localhost:9001"},
 			},
@@ -48,7 +48,7 @@ func TestValidate(t *testing.T) {
 		},
 		{
 			name: "invalid config with unsupported protocol",
-			cfg: Config{
+			cfg: Configuration{
 				Protocol:  "grpc",
 				Addresses: []string{"localhost:9000"},
 			},
@@ -56,7 +56,7 @@ func TestValidate(t *testing.T) {
 		},
 		{
 			name: "invalid config with empty addresses",
-			cfg: Config{
+			cfg: Configuration{
 				Protocol:  "native",
 				Addresses: []string{},
 			},
@@ -64,7 +64,7 @@ func TestValidate(t *testing.T) {
 		},
 		{
 			name: "invalid config with nil addresses",
-			cfg: Config{
+			cfg: Configuration{
 				Protocol: "native",
 			},
 			wantErr: true,
diff --git a/internal/storage/v2/clickhouse/depstore/package_test.go b/internal/storage/v2/clickhouse/depstore/package_test.go
new file mode 100644
index 00000000..bae65cef
--- /dev/null
+++ b/internal/storage/v2/clickhouse/depstore/package_test.go
@@ -0,0 +1,14 @@
+// Copyright (c) 2025 The Jaeger Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package depstore
+
+import (
+	"testing"
+
+	"github.com/jaegertracing/jaeger/internal/testutils"
+)
+
+func TestMain(m *testing.M) {
+	testutils.VerifyGoLeaks(m)
+}
diff --git a/internal/storage/v2/clickhouse/depstore/reader_test.go b/internal/storage/v2/clickhouse/depstore/reader_test.go
new file mode 100644
index 00000000..b9ce4be1
--- /dev/null
+++ b/internal/storage/v2/clickhouse/depstore/reader_test.go
@@ -0,0 +1,23 @@
+// Copyright (c) 2025 The Jaeger Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package depstore
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/jaegertracing/jaeger/internal/storage/v2/api/depstore"
+)
+
+func TestReader_GetDependencies(t *testing.T) {
+	reader := NewDependencyReader()
+	ctx := context.Background()
+	query := depstore.QueryParameters{}
+
+	require.Panics(t, func() {
+		reader.GetDependencies(ctx, query)
+	})
+}
diff --git a/internal/storage/v2/clickhouse/factory_test.go b/internal/storage/v2/clickhouse/factory_test.go
new file mode 100644
index 00000000..43718702
--- /dev/null
+++ b/internal/storage/v2/clickhouse/factory_test.go
@@ -0,0 +1,235 @@
+// Copyright (c) 2025 The Jaeger Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package clickhouse
+
+import (
+	"context"
+	"errors"
+	"testing"
+	"time"
+
+	"github.com/ClickHouse/clickhouse-go/v2"
+	"github.com/open-telemetry/opentelemetry-collector-contrib/extension/basicauthextension"
+	"github.com/stretchr/testify/require"
+	"go.opentelemetry.io/collector/config/configoptional"
+
+	"github.com/jaegertracing/jaeger/internal/storage/v2/clickhouse/clickhousetest"
+	"github.com/jaegertracing/jaeger/internal/storage/v2/clickhouse/sql"
+	"github.com/jaegertracing/jaeger/internal/telemetry"
+)
+
+func TestFactory(t *testing.T) {
+	tests := []struct {
+		name         string
+		createSchema bool
+	}{
+		{
+			name:         "without schema creation",
+			createSchema: false,
+		},
+		{
+			name:         "with schema creation",
+			createSchema: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			srv := clickhousetest.NewServer(clickhousetest.FailureConfig{})
+			defer srv.Close()
+
+			cfg := Configuration{
+				Protocol: "http",
+				Addresses: []string{
+					srv.Listener.Addr().String(),
+				},
+				Database: "default",
+				Auth: Authentication{
+					Basic: configoptional.Some(basicauthextension.ClientAuthSettings{
+						Username: "user",
+						Password: "password",
+					}),
+				},
+			}
+
+			f, err := NewFactory(context.Background(), cfg, telemetry.Settings{})
+			require.NoError(t, err)
+			require.NotNil(t, f)
+
+			tr, err := f.CreateTraceReader()
+			require.NoError(t, err)
+			require.NotNil(t, tr)
+
+			tw, err := f.CreateTraceWriter()
+			require.NoError(t, err)
+			require.NotNil(t, tw)
+
+			dr, err := f.CreateDependencyReader()
+			require.NoError(t, err)
+			require.NotNil(t, dr)
+
+			err = f.Purge(context.Background())
+			require.NoError(t, err)
+
+			require.NoError(t, f.Close())
+		})
+	}
+}
+
+func TestNewFactory_Errors(t *testing.T) {
+	tests := []struct {
+		name          string
+		failureConfig clickhousetest.FailureConfig
+		expectedError string
+	}{
+		{
+			name: "ping error",
+			failureConfig: clickhousetest.FailureConfig{
+				clickhousetest.PingQuery: errors.New("ping error"),
+			},
+			expectedError: "failed to ping ClickHouse",
+		},
+		{
+			name: "spans table creation error",
+			failureConfig: clickhousetest.FailureConfig{
+				sql.CreateSpansTable: errors.New("spans table creation error"),
+			},
+			expectedError: "failed to create spans table",
+		},
+		{
+			name: "services table creation error",
+			failureConfig: clickhousetest.FailureConfig{
+				sql.CreateServicesTable: errors.New("services table creation error"),
+			},
+			expectedError: "failed to create services table",
+		},
+		{
+			name: "services materialized view creation error",
+			failureConfig: clickhousetest.FailureConfig{
+				sql.CreateServicesMaterializedView: errors.New("services materialized view creation error"),
+			},
+			expectedError: "failed to create services materialized view",
+		},
+		{
+			name: "operations table creation error",
+			failureConfig: clickhousetest.FailureConfig{
+				sql.CreateOperationsTable: errors.New("operations table creation error"),
+			},
+			expectedError: "failed to create operations table",
+		},
+		{
+			name: "operations materialized view creation error",
+			failureConfig: clickhousetest.FailureConfig{
+				sql.CreateOperationsMaterializedView: errors.New("operations materialized view creation error"),
+			},
+			expectedError: "failed to create operations materialized view",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			srv := clickhousetest.NewServer(tt.failureConfig)
+			defer srv.Close()
+
+			cfg := Configuration{
+				Protocol: "http",
+				Addresses: []string{
+					srv.Listener.Addr().String(),
+				},
+				DialTimeout:  1 * time.Second,
+				CreateSchema: true,
+			}
+
+			f, err := NewFactory(context.Background(), cfg, telemetry.Settings{})
+			require.ErrorContains(t, err, tt.expectedError)
+			require.Nil(t, f)
+		})
+	}
+}
+
+func TestPurge(t *testing.T) {
+	tests := []struct {
+		name          string
+		failureConfig clickhousetest.FailureConfig
+		expectedError string
+	}{
+		{
+			name: "truncate spans table error",
+			failureConfig: clickhousetest.FailureConfig{
+				sql.TruncateSpans: errors.New("truncate spans table error"),
+			},
+			expectedError: "failed to purge spans",
+		},
+		{
+			name: "truncate services table error",
+			failureConfig: clickhousetest.FailureConfig{
+				sql.TruncateServices: errors.New("truncate services table error"),
+			},
+			expectedError: "failed to purge services",
+		},
+		{
+			name: "truncate operations table error",
+			failureConfig: clickhousetest.FailureConfig{
+				sql.TruncateOperations: errors.New("truncate operations table error"),
+			},
+			expectedError: "failed to purge operations",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			srv := clickhousetest.NewServer(tt.failureConfig)
+			defer srv.Close()
+
+			cfg := Configuration{
+				Protocol: "http",
+				Addresses: []string{
+					srv.Listener.Addr().String(),
+				},
+				DialTimeout:  1 * time.Second,
+				CreateSchema: true,
+			}
+
+			f, err := NewFactory(context.Background(), cfg, telemetry.Settings{})
+			require.NoError(t, err)
+			t.Cleanup(func() {
+				require.NoError(t, f.Close())
+			})
+
+			err = f.Purge(context.Background())
+			require.ErrorContains(t, err, tt.expectedError)
+		})
+	}
+}
+
+func TestGetProtocol(t *testing.T) {
+	tests := []struct {
+		protocol string
+		expected clickhouse.Protocol
+	}{
+		{
+			protocol: "http",
+			expected: clickhouse.HTTP,
+		},
+		{
+			protocol: "native",
+			expected: clickhouse.Native,
+		},
+		{
+			protocol: "",
+			expected: clickhouse.Native,
+		},
+		{
+			protocol: "unknown",
+			expected: clickhouse.Native,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.protocol, func(t *testing.T) {
+			result := getProtocol(tt.protocol)
+			require.Equal(t, tt.expected, result)
+		})
+	}
+}
diff --git a/internal/storage/v2/clickhouse/package_test.go b/internal/storage/v2/clickhouse/package_test.go
new file mode 100644
index 00000000..e330a28c
--- /dev/null
+++ b/internal/storage/v2/clickhouse/package_test.go
@@ -0,0 +1,14 @@
+// Copyright (c) 2025 The Jaeger Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package clickhouse
+
+import (
+	"testing"
+
+	"github.com/jaegertracing/jaeger/internal/testutils"
+)
+
+func TestMain(m *testing.M) {
+	testutils.VerifyGoLeaks(m)
+}
diff --git a/internal/storage/v2/clickhouse/tracestore/factory_test.go b/internal/storage/v2/clickhouse/tracestore/factory_test.go
deleted file mode 100644
index 6150ca73..00000000
--- a/internal/storage/v2/clickhouse/tracestore/factory_test.go
+++ /dev/null
@@ -1,136 +0,0 @@
-// Copyright (c) 2025 The Jaeger Authors.
-// SPDX-License-Identifier: Apache-2.0
-
-package tracestore
-
-import (
-	"context"
-	"io"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-	"time"
-
-	"github.com/ClickHouse/ch-go/proto"
-	"github.com/ClickHouse/clickhouse-go/v2"
-	chproto "github.com/ClickHouse/clickhouse-go/v2/lib/proto"
-	"github.com/open-telemetry/opentelemetry-collector-contrib/extension/basicauthextension"
-	"github.com/stretchr/testify/require"
-	"go.opentelemetry.io/collector/config/configoptional"
-
-	"github.com/jaegertracing/jaeger/internal/telemetry"
-)
-
-var (
-	pingQuery      = "SELECT 1"
-	handshakeQuery = "SELECT displayName(), version(), revision(), timezone()"
-)
-
-func newMockClickHouseServer() *httptest.Server {
-	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		body, _ := io.ReadAll(r.Body)
-		query := string(body)
-
-		block := chproto.NewBlock()
-
-		switch query {
-		case pingQuery:
-			block.AddColumn("1", "UInt8")
-			block.Append(uint8(1))
-		case handshakeQuery:
-			block.AddColumn("displayName()", "String")
-			block.AddColumn("version()", "String")
-			block.AddColumn("revision()", "UInt32")
-			block.AddColumn("timezone()", "String")
-			block.Append("mock-server", "23.3.1", chproto.DBMS_MIN_REVISION_WITH_CUSTOM_SERIALIZATION, "UTC")
-		default:
-		}
-
-		var buf proto.Buffer
-		block.Encode(&buf, clickhouse.ClientTCPProtocolVersion)
-
-		w.Header().Set("Content-Type", "application/octet-stream")
-		w.Write(buf.Buf)
-	}))
-}
-
-func TestFactory(t *testing.T) {
-	srv := newMockClickHouseServer()
-	defer srv.Close()
-
-	cfg := Config{
-		Protocol: "http",
-		Addresses: []string{
-			srv.Listener.Addr().String(),
-		},
-		Database: "default",
-		Auth: Authentication{
-			Basic: configoptional.Some(basicauthextension.ClientAuthSettings{
-				Username: "user",
-				Password: "password",
-			}),
-		},
-	}
-
-	f, err := NewFactory(context.Background(), cfg, telemetry.Settings{})
-	require.NoError(t, err)
-	require.NotNil(t, f)
-
-	tr, err := f.CreateTraceReader()
-	require.NoError(t, err)
-	require.NotNil(t, tr)
-
-	tw, err := f.CreateTraceWriter()
-	require.NoError(t, err)
-	require.NotNil(t, tw)
-
-	require.NoError(t, f.Close())
-}
-
-func TestFactory_PingError(t *testing.T) {
-	srv := newMockClickHouseServer()
-	defer srv.Close()
-
-	cfg := Config{
-		Protocol: "http",
-		Addresses: []string{
-			"127.0.0.1:9999", // wrong address to simulate ping error
-		},
-		DialTimeout: 1 * time.Second,
-	}
-
-	f, err := NewFactory(context.Background(), cfg, telemetry.Settings{})
-	require.ErrorContains(t, err, "failed to ping ClickHouse")
-	require.Nil(t, f)
-}
-
-func TestGetProtocol(t *testing.T) {
-	tests := []struct {
-		protocol string
-		expected clickhouse.Protocol
-	}{
-		{
-			protocol: "http",
-			expected: clickhouse.HTTP,
-		},
-		{
-			protocol: "native",
-			expected: clickhouse.Native,
-		},
-		{
-			protocol: "",
-			expected: clickhouse.Native,
-		},
-		{
-			protocol: "unknown",
-			expected: clickhouse.Native,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.protocol, func(t *testing.T) {
-			result := getProtocol(tt.protocol)
-			require.Equal(t, tt.expected, result)
-		})
-	}
-}
diff --git a/internal/storage/v2/clickhouse/tracestore/writer_test.go b/internal/storage/v2/clickhouse/tracestore/writer_test.go
index 252c1565..6cf5bab0 100644
--- a/internal/storage/v2/clickhouse/tracestore/writer_test.go
+++ b/internal/storage/v2/clickhouse/tracestore/writer_test.go
@@ -6,6 +6,7 @@ package tracestore
 import (
 	"context"
 	"encoding/hex"
+	"strings"
 	"testing"
 	"time"
 
@@ -71,19 +72,19 @@ func TestWriter_Success(t *testing.T) {
 	for i, expected := range multipleSpans {
 		row := conn.batch.appended[i]
 
-		require.Equal(t, expected.id, row[0])                // SpanID
-		require.Equal(t, expected.traceID, row[1])           // TraceID
-		require.Equal(t, expected.traceState, row[2])        // TraceState
-		require.Equal(t, expected.parentSpanID, row[3])      // ParentSpanID
-		require.Equal(t, expected.name, row[4])              // Name
-		require.Equal(t, expected.kind, row[5])              // Kind
-		require.Equal(t, expected.startTime, row[6])         // StartTimestamp
-		require.Equal(t, expected.statusCode, row[7])        // Status code
-		require.Equal(t, expected.statusMessage, row[8])     // Status message
-		require.EqualValues(t, expected.rawDuration, row[9]) // Duration
-		require.Equal(t, expected.serviceName, row[10])      // Service name
-		require.Equal(t, expected.scopeName, row[11])        // Scope name
-		require.Equal(t, expected.scopeVersion, row[12])     // Scope version
+		require.Equal(t, expected.id, row[0])                    // SpanID
+		require.Equal(t, expected.traceID, row[1])               // TraceID
+		require.Equal(t, expected.traceState, row[2])            // TraceState
+		require.Equal(t, expected.parentSpanID, row[3])          // ParentSpanID
+		require.Equal(t, expected.name, row[4])                  // Name
+		require.Equal(t, strings.ToLower(expected.kind), row[5]) // Kind
+		require.Equal(t, expected.startTime, row[6])             // StartTimestamp
+		require.Equal(t, expected.statusCode, row[7])            // Status code
+		require.Equal(t, expected.statusMessage, row[8])         // Status message
+		require.EqualValues(t, expected.rawDuration, row[9])     // Duration
+		require.Equal(t, expected.serviceName, row[10])          // Service name
+		require.Equal(t, expected.scopeName, row[11])            // Scope name
+		require.Equal(t, expected.scopeVersion, row[12])         // Scope version
 	}
 }