Hoist-core provides distributed tracing built on OpenTelemetry, using the OTel SDK directly for span processing and export. The system supports OTLP export and is configured dynamically via soft config.
Tracing is disabled by default and has negligible overhead when disabled β all public methods delegate to no-op implementations, so no null checks are needed in application code.
- Central service β
TraceServicemanages the OpenTelemetry SDK lifecycle, exporter pipeline, and provides thewithSpanspan creation API. - Combined observability β
ObservedRunis a composable builder that wraps a closure with any combination of tracing, logging, and metrics. Typically started viaBaseService.span(), withBaseService.observe()available for the rare case where no span is wanted. - Automatic request spans β
HoistFiltercreates a SERVER span for each request, extracting any inbound W3Ctraceparentso the span joins an existing trace. Pings, version checks, and websocket handshakes are excluded. - Export β OTLP (HTTP/protobuf) export configured via soft config. Applications can register
additional exporters (e.g. Zipkin) via
addExporter(). - W3C trace propagation β incoming
traceparentheaders are honored; outbound HTTP calls viaJSONClientandBaseProxyServiceinject trace context automatically. - Cluster context propagation β
ClusterTaskcaptures and restores trace context across Hazelcast remote execution, maintaining parent-child span relationships. - Thread context propagation β Grails
task {}calls automatically carry the current trace context to worker threads viaHoistPromiseFactory. - Client span relay β Browser-generated spans are submitted to
xh/submitSpansand exported through the same server-side pipeline, producing end-to-end client-to-server traces. Client spans are pre-sampled in the browser β only sampled spans are relayed. - Rule-based sampling β Configurable
sampleRulesmatch span tags (with glob patterns) to determine per-span sample rates. - Log correlation β
traceIdis captured on log markers when inside a traced context, enabling log-to-trace correlation.
| File | Location | Role |
|---|---|---|
TraceService.groovy |
grails-app/services/io/xh/hoist/telemetry/trace/ |
Central tracing service β SDK lifecycle, exporter pipeline, span API |
JdbcTraceService.groovy |
grails-app/services/io/xh/hoist/telemetry/trace/impl/ |
Internal service that installs/uninstalls JDBC DataSource instrumentation in sync with the trace config |
TraceContextService.groovy |
grails-app/services/io/xh/hoist/telemetry/trace/ |
Internal service hosting W3C context propagation β inbound filter, outbound HTTP, cluster tasks |
HoistFilter.groovy |
src/main/groovy/io/xh/hoist/ |
Wraps every request β restores trace context, enforces auth, creates the SERVER span for tracing, captures any exception, and stamps HTTP semantic-convention attributes |
SpanRef.groovy |
src/main/groovy/io/xh/hoist/telemetry/trace/ |
Wrapper around an active Span + Scope with tag/status/error helpers |
ObservedRun.groovy |
src/main/groovy/io/xh/hoist/telemetry/ |
Composable builder for combined tracing + logging + metrics |
TraceConfig.groovy |
src/main/groovy/io/xh/hoist/telemetry/trace/ |
Typed wrapper around xhTraceConfig |
HoistPromiseFactory.groovy |
src/main/groovy/io/xh/hoist/ |
Wraps the Grails PromiseFactory to propagate framework thread context β including OTel trace context β to task {} worker threads |
DelegatingOpenTelemetry.groovy |
src/main/groovy/io/xh/hoist/telemetry/trace/ |
Stable OpenTelemetry facade that resolves to the current SDK on every tracer/span lookup β lets library instrumentation (e.g. opentelemetry-jdbc) capture a reference once and follow SDK rebuilds |
TagSpanProcessor |
Inner class of TraceService.groovy |
Stamps cross-cutting attributes (e.g. user.name) on every span at start time, regardless of whether the span was created by TraceService or by a library instrumenter |
ManualRateSampler.groovy |
src/main/groovy/io/xh/hoist/telemetry/trace/ |
Per-span thread-local sampler driven by sampleRules and the fallback sampleRate |
ClientSpanData.groovy |
src/main/groovy/io/xh/hoist/telemetry/trace/ |
Adapts client-relayed span JSON into OTel SpanData for export through the server pipeline |
File: grails-app/services/io/xh/hoist/telemetry/trace/TraceService.groovy
The central service for distributed tracing. Initialized early in the bootstrap sequence β
immediately after ConfigService, so the remainder of startup (including MetricsService
initialization) runs inside a trace span. Manages the OpenTelemetry SDK and exporter pipeline,
and provides the span creation API.
Execute a closure within a new trace span. Creates a child span if a parent context exists, or a
root span otherwise. Exceptions are recorded on the span and re-thrown. The closure may optionally
accept a SpanRef parameter, which may be further enhanced with tags, or information about errors.
Note that a NoOp span is passed even if tracing is disabled.
For combined tracing + logging + metrics, use ObservedRun via BaseService.span() instead.
Arguments (passed as named params):
| Key | Type | Description |
|---|---|---|
name |
String | Span name (required). |
kind |
SpanKind | INTERNAL (default), SERVER, or CLIENT. |
tags |
Map | Key-value attributes to set on the span. |
caller |
Object | Object making the call, auto-sets the code.namespace attribute. |
startTime |
Instant | Optional backdated start; defaults to now. |
traceService.withSpan(name: 'fetchData', kind: SpanKind.CLIENT, tags: [url: endpoint]) { SpanRef span ->
def result = httpClient.get(endpoint)
span.setHttpStatusAndErrorStatus(result.statusCode)
result
}Creates and starts a span with a manually managed lifecycle, returning a SpanRef the caller must
close. Private to TraceService β not callable from application code. Its parameters are the
source of truth for the withSpan arguments documented above, which passes them straight through.
Register additional SpanExporter instances to receive both server-generated and client-relayed
spans. Triggers a pipeline rebuild. Use this to add custom export destinations (e.g. Zipkin):
traceService.addExporter(
ZipkinSpanExporter.builder()
.setEndpoint('http://zipkin:9411/api/v2/spans')
.build()
)File: src/main/groovy/io/xh/hoist/telemetry/trace/SpanRef.groovy
A wrapper around an active OTel Span and its Scope, passed to the closures run by withSpan and
ObservedRun.run. Use it to enrich a span in flight with tags, a revised name, or the outcome of
the work.
A shared no-op instance (SpanRef.NOOP) is passed when tracing is disabled, so closure bodies can
call these methods unconditionally without null-checking.
| Method | Description |
|---|---|
setTag(key, value) |
Set a single attribute. Coerces Integer/Long β long, Boolean β boolean, Double β double, anything else via toString(). |
setTags(map) |
Set multiple attributes, with the same coercion rules. |
updateName(name) |
Revise the span's display name β useful once a generic operation resolves to something more specific. |
setErrorStatus(description?) |
Mark ERROR with an optional description, no exception required. |
recordException(t) |
Record an exception event. Leaves span status untouched. No-op for RoutineException. |
recordExceptionAndErrorStatus(t) |
Record an exception event and mark ERROR, with a description derived from the throwable. No-op for RoutineException. |
setHttpStatusAndErrorStatus(code) |
Set the http.response.status_code tag and mark ERROR per OTel HTTP conventions: CLIENT spans at β₯400, SERVER spans at β₯500. |
close() |
Close the scope and end the span. Handled for you by withSpan and ObservedRun.run. |
Not every failure arrives as a Throwable or an HTTP status β a validation rejection, a
well-formed but unusable payload from a dependency, or a batch that completes with unrecoverable
records are all real failures with nothing to catch. Use setErrorStatus for these:
span(name: 'importOrders').run { SpanRef span ->
def result = orderImporter.run()
span.setTag('rejectedCount', result.rejected.size())
if (result.rejected) {
span.setErrorStatus("Rejected ${result.rejected.size()} of ${result.total} orders")
}
result
}Do not synthesize a throwable just to flag a span. Beyond the fabricated stack trace, Datadog's
OTLP intake maps any exception event onto error.* tags, so an invented exception yields an
invented error.type and error.stack.
Note that setErrorStatus is unconditional β unlike the recordException methods, it does not skip
RoutineException cases, since the caller is explicitly asking for the status. Also note that span
status is last-call-wins, so a later recordExceptionAndErrorStatus on the same span will overwrite
an earlier description.
File: src/main/groovy/io/xh/hoist/telemetry/ObservedRun.groovy
A composable builder for wrapping a closure with any combination of tracing, logging, and metrics.
Each concern is opt-in via dedicated builder methods, then executed with run(). The closure is
wrapped in an onion from outermost to innermost: span β log β metrics β closure.
Access via BaseService.span(name, kind?, tags?), which creates a builder pre-configured with
the service as the caller (used for span code.namespace and log context) and an initial span.
Additional builder methods (logInfo, timer, counter, etc.) can be chained as needed.
BaseService.observe() returns the same builder without an initial span β use it only when no
span is wanted.
| Method | Description |
|---|---|
.span(name, kind?, tags?) |
Configure a trace span. |
.logInfo(msg) |
Log at INFO via LogSupport.withInfo. |
.logDebug(msg) |
Log at DEBUG via LogSupport.withDebug. |
.logTrace(msg) |
Log at TRACE via LogSupport.withTrace. |
.timer(name, tags?) |
Record elapsed time on the named Micrometer Timer. |
.counter(name, tags?) |
Increment the named Micrometer Counter. |
.run(closure) |
Terminal β execute with all configured observability. |
When multiple log levels are configured, ObservedRun selects the finest enabled level at
run() time. This allows callers to specify both a coarse and fine message β the finest enabled
level wins:
span('importData')
.logInfo('Importing data')
.logDebug(['Importing data', [source: url, batchSize: n]])
.run {
// If DEBUG is enabled: logs with the detailed debug message
// Otherwise: logs with the shorter info message
}Span + log + timer (most common pattern):
class PortfolioService extends BaseService {
private Portfolio generatePortfolio() {
span('generatePortfolio')
.logInfo('Generating Portfolio')
.timer('generatePortfolio')
.run {
// business logic
}
}
}Both .timer() and .counter() add an xh.outcome tag of success or failure on completion,
based on whether the closure threw. Metric names are prefixed with BaseService.telemetryPrefix
when set on the owning service β pass useNamePrefix: false to opt out.
Span + log only:
span('generateOrders')
.logDebug("Generating ${count} orders")
.run {
// business logic
}Span only (with SpanRef access):
For the common case of a span without logging or metrics, BaseService.span() is a shortcut for
observe().span(...):
span(name: 'processOrder', tags: [orderId: id])
.run { SpanRef span ->
def result = doWork()
span.setTag('resultCount', result.size())
result
}Standalone (no BaseService):
ObservedRun.observe(this)
.span('myOp')
.logDebug('Working')
.run {
// works from any LogSupport implementor
}| Property | Value |
|---|---|
| Type | json |
| Default | See below |
| Client Visible | Yes (client reads enabled, sampleRate, and sampleRules for browser tracing) |
| Purpose | Distributed tracing infrastructure configuration. |
Default value:
{
"enabled": false,
"sampleRate": 1.0,
"sampleRules": [],
"jdbcTracingEnabled": false,
"otlpEnabled": false,
"otlpConfig": {}
}| Key | Type | Description |
|---|---|---|
enabled |
Boolean | Master switch for tracing. When false, all tracing is no-op. Dynamic. |
sampleRate |
Double | Fallback sampling rate (0.0β1.0) applied when no sampling rule matches. Dynamic. |
sampleRules |
List<Map> | Ordered rules for per-span sampling. Each rule has a match map of tag patterns (plus the reserved name key that matches the span's name) and a sampleRate. First match wins; unmatched spans use the fallback sampleRate. See Sampling Rules below. Dynamic. |
jdbcTracingEnabled |
Boolean | Emit CLIENT spans for all JDBC DataSource operations β applies to every pool (primary + any additional Grails datasources). Defaults to false. Dynamic. See JDBC below. |
otlpEnabled |
Boolean | Enable OTLP span export (HTTP/protobuf). Dynamic. In local development, additionally gated β see Local-development gating. |
otlpConfig |
Map | OTLP exporter config (e.g. {"endpoint": "http://localhost:4318/v1/traces"}). |
When xhTraceConfig is updated, the exporter pipeline is torn down and recreated. This is
handled by clearCaches() responding to the xhConfigChanged event.
When otlpEnabled: true, spans are exported via HTTP/protobuf to an OTLP-compatible backend
(e.g. Jaeger, Grafana Tempo, Datadog). Configure the endpoint via otlpConfig:
{
"enabled": true,
"sampleRate": 1.0,
"otlpEnabled": true,
"otlpConfig": {
"endpoint": "http://localhost:4318/v1/traces",
"timeout": "30000"
}
}OTLP export is suppressed by default when the app is running in local development, even when
otlpEnabled: true in xhTraceConfig. This avoids polluting a shared OTLP backend with
developer-machine spans during routine work. The same gating applies to metrics export β see
metrics.md.
To opt in, set the otlpEnabledInLocalDev instance config to 'true'. Local-development
detection follows Utils.isLocalDevelopment, which reflects the Grails runtime mode
(Environment.isDevelopmentMode() β true when started via bootRun, false in a deployed war).
This is independent of the configured appEnvironment, so a deployed instance configured as
Development is not affected by this flag.
When OTLP export runs in local dev, the deployment.environment.name resource attribute is
suffixed with the OS username (e.g. Development-johndoe) so per-developer data can be
distinguished in a shared backend. Override
ClusterConfig.getOtelResourceAttributes()
if your backend prefers a different scheme.
Applications can register additional exporters via traceService.addExporter(). These
receive both server-generated and client-relayed spans.
Sampling rules provide fine-grained, tag-based control over which spans are sampled. Rules are
evaluated at span creation time (head-based sampling) on both client and server. Each rule has a
match map of tag patterns and a sampleRate β the first matching rule wins.
The reserved key name matches against the span's name (not a tag), using the same glob syntax as
tag-value patterns.
namematches the name at creation time, not the final name. Request spans are created as the bare HTTP method (GET) and only renamed to{METHOD} {controller}/{action}after routing β so a rule like{"name": "GET xh/health*"}never matches. To target requests by route, match theurl.pathtag, which is set before the sampling decision.
Add rules to the sampleRules array in xhTraceConfig:
{
"enabled": true,
"sampleRate": 0.1,
"sampleRules": [
{"match": {"url.path": "/xh/health*"}, "sampleRate": 0},
{"match": {"xh.source": "hoist"}, "sampleRate": 0.01},
{"match": {"user.name": "jsmith"}, "sampleRate": 1.0}
]
}In this example: health-check requests are dropped entirely, framework-generated spans are sampled
at 1%, spans from user jsmith are always sampled, and everything else falls back to the 10%
default.
String tag values support simple glob patterns:
| Pattern | Matches |
|---|---|
* |
Any value |
foo* |
Values starting with foo |
*foo |
Values ending with foo |
*foo* |
Values containing foo |
foo |
Exact match |
Non-string values (numbers, booleans) use strict equality.
- Tags are assembled on the span before the sampling decision.
- If a valid parent context exists, the child inherits its decision β including an unsampled parent, which drops the child. Rules are not consulted.
- Otherwise,
sampleRulesare evaluated against the span's name and tags. The first rule whosematchentries all match produces thesampleRatefor a probabilistic decision. - Unmatched spans use the fallback
sampleRate. - Unsampled spans are dropped β not recorded, and never exported.
The client-side TraceService in hoist-react evaluates the same sampleRules config, so
sampling decisions are consistent across client and server spans.
HoistFilter extracts incoming W3C traceparent headers from the request, restoring the
client's trace context. It then wraps the whole of request handling β cluster readiness check,
authentication, and chain.doFilter β in a SERVER span, which becomes a child of the client
span when a traceparent was present. Because the span encloses auth, rejected requests are
traced too.
Pings (/ping, /xh/ping), /xh/version, and websocket handshakes are not traced.
- Name: created as the bare HTTP method (
GET), the only information available pre-routing.HoistInterceptorthen renames it to{METHOD} {controller}/{action}(e.g.GET portfolio/positions) and adds thehttp.routetag once routing resolves. See the note under Sampling Rules β the sampling decision sees only the pre-routing name. - Attributes (at creation):
http.request.method,url.path,url.scheme,server.address,server.port,client.address,user_agent.original,xh.source=hoist. Thenhttp.routefromHoistInterceptor, andhttp.response.status_codewhen the span closes. - Exceptions escaping dispatch are rendered by Hoist's
ExceptionHandlerand then recorded on the span viarecordException. Exceptions handled inside a controller action are recorded byBaseController.
All outbound HTTP calls via JSONClient automatically:
- Create a CLIENT span named with the HTTP method (e.g.
POST) - Inject W3C
traceparentheaders onto the outbound request
- Attributes:
http.request.method,url.full,server.address,server.port,http.response.status_code,xh.source=hoist
Proxied requests via BaseProxyService automatically:
- Create a CLIENT span named with the HTTP method (e.g.
GET) - Inject W3C trace context onto the proxied request
- Attributes:
http.request.method,url.full,server.address,http.response.status_code,xh.source=hoist
At startup, JdbcTraceService.init() walks down each DataSource's proxy chain to the
underlying raw pool and swaps it for an OpenTelemetryDataSource wrap. Wrapping at the
bottom of the chain means every consumer sharing the chain β Spring DI, direct JDBC,
Hibernate/GORM β is instrumented by a single wrap. The wrap is a no-op unless
jdbcTracingEnabled is true on xhTraceConfig, so toggling JDBC tracing is a runtime
config change β no restart or re-wrapping needed.
When enabled, CLIENT spans are emitted for each connection acquire and statement execution,
parented under whatever span is active at query time β typically the request span created by
HoistFilter, but also timer tasks, withSpan blocks, and cluster tasks.
- Name:
{operation} {schema}.{table}where derivable (e.g.SELECT xh_app_config), otherwise the bare operation. - Attributes: standard OTel DB semconv β
db.system,db.namespace,db.statement,server.address,server.port, etc.
Enabling.
{
"enabled": true,
"jdbcTracingEnabled": true
}jdbcTracingEnabled is inert unless the master enabled flag is also true.
Multi-datasource apps. Grails apps configured with multiple datasources
(dataSource_reporting, sessionFactory_reporting, etc.) are handled automatically β
JdbcTraceService iterates every Spring DataSource bean and every Hibernate
SessionFactory, covering both direct-JDBC and GORM paths. The flag applies uniformly to
all pools; there's no per-pool gating.
ClusterTask captures the current traceparent string at construction time and restores
it on the remote instance before execution. This means runOnAllInstances(...) calls from
within a traced request produce child spans on remote instances.
The traceparent is a plain String field that serializes naturally with Kryo. When no active
trace context exists at construction time, traceparent is null and context restoration is
skipped.
At startup, HoistCoreGrailsPlugin installs a HoistPromiseFactory that wraps the default Grails
PromiseFactory. This ensures that every task {} call β the primary async dispatch
mechanism in Hoist β automatically carries the calling thread's OTel trace context to the
worker thread, alongside the other framework context it propagates.
This covers all Grails task {} usage across the codebase (e.g. asyncEach, LdapService,
MonitorEvalService, TrackService) without any per-call-site changes.
The client-side TraceService sends a traceparent header on every fetch request.
HoistFilter extracts this header and restores the trace context, so the SERVER span it creates
becomes a child of the client's span β producing end-to-end traces from browser interaction
through server processing.
Browser-generated spans are batched and posted to the xh/submitSpans endpoint, which routes
to TraceService.submitClientSpans(). That method converts each entry into an OTel SpanData
(via ClientSpanData) β preserving the original trace/span IDs β and exports it through the
same pipeline as server-generated spans. Client and server spans appear as a coherent
distributed trace in the collector.
Client spans are pre-sampled in the browser using the shared sampleRules config β only
sampled spans are relayed to the server.
LogSupportMarker captures the active traceId at logging time. The default
LogSupportConverter appends traceId=... to log output for ERROR-level and above, enabling
correlation between logs and traces in observability platforms.
All spans include these resource attributes identifying the source instance:
| Attribute | Value |
|---|---|
service.name |
Application code (e.g. myApp) |
service.instance.id |
Cluster instance name (e.g. inst1) |
deployment.environment.name |
Hoist AppEnvironment (e.g. PRODUCTION) |
service.version |
Application version |