diff --git a/content/develop/clients/error-handling.md b/content/develop/clients/error-handling.md index a78141a57b..bb563802d7 100644 --- a/content/develop/clients/error-handling.md +++ b/content/develop/clients/error-handling.md @@ -277,7 +277,10 @@ graph LR In production, you may find it useful to log errors when they occur and monitor the logs for patterns. This can help you identify which errors are most common and whether your retry and fallback -strategies are effective. +strategies are effective. Note that some Redis client +libraries have built-in instrumentation that can provide this +information for you (see [Observability]({{< relref "/develop/clients/observability" >}}) +for a full description). ### What to log diff --git a/content/develop/clients/go/_index.md b/content/develop/clients/go/_index.md index c9e7f9c42b..dec20d6399 100644 --- a/content/develop/clients/go/_index.md +++ b/content/develop/clients/go/_index.md @@ -100,34 +100,6 @@ func main() { } ``` -## Observability - -`go-redis` supports [OpenTelemetry](https://opentelemetry.io/) instrumentation. -to monitor performance and trace the execution of Redis commands. -For example, the following code instruments Redis commands to collect traces, logs, and metrics: - -```go -import ( - "github.com/redis/go-redis/v9" - "github.com/redis/go-redis/extra/redisotel/v9" -) - -client := redis.NewClient(&redis.Options{...}) - -// Enable tracing instrumentation. -if err := redisotel.InstrumentTracing(client); err != nil { - panic(err) -} - -// Enable metrics instrumentation. -if err := redisotel.InstrumentMetrics(client); err != nil { - panic(err) -} -``` - -See the `go-redis` [GitHub repo](https://github.com/redis/go-redis/blob/master/example/otel/README.md). -for more OpenTelemetry examples. - ## More information See the other pages in this section for more information and examples. diff --git a/content/develop/clients/go/observability.md b/content/develop/clients/go/observability.md new file mode 100644 index 0000000000..6a104701c1 --- /dev/null +++ b/content/develop/clients/go/observability.md @@ -0,0 +1,114 @@ +--- +categories: +- docs +- develop +- stack +- oss +- rs +- rc +- oss +- kubernetes +- clients +description: Monitor your client's activity for optimization and debugging. +linkTitle: Observability +title: Observability +weight: 75 +--- + +`go-redis` has built-in support for [OpenTelemetry](https://opentelemetry.io/) (OTel) +instrumentation to collect metrics. This can be very helpful for +diagnosing problems and improving the performance and connection resiliency of +your application. See the +[Observability overview]({{< relref "/develop/clients/observability" >}}) +for an introduction to Redis client observability and a reference guide for the +available metrics. + +This page explains how to enable and use OTel instrumentation +in `go-redis` using an example configuration for a local [Grafana](https://grafana.com/) +instance. See our +[observability demonstration repository](https://github.com/redis-developer/redis-client-observability) +on GitHub to learn how to set up a suitable Grafana dashboard. + +## Installation + +Install OTel support for `go-redis` with the following commands: + +```bash +go get github.com/redis/go-redis/extra/redisotel-native/v9 +go get go.opentelemetry.io/otel +``` + +## Import + +Start by importing the required OTel and Redis modules: + +{{< clients-example set="observability" step="import" lang_filter="Go" description="Foundational: Import required libraries for Redis observability, OpenTelemetry metrics, and Redis operations" difficulty="beginner" >}} +{{< /clients-example >}} + +## Configure the meter provider + +Otel uses a [Meter provider](https://opentelemetry.io/docs/concepts/signals/metrics/#meter-provider) +to create the objects that collect the metric information. The example below +configures a meter provider to export metrics to a local Grafana instance +every 10 seconds, but see the [OpenTelemetry Go docs](https://opentelemetry.io/docs/languages/go/) +to learn more about other export options. + +{{< clients-example set="observability" step="setup_meter_provider" lang_filter="Go" description="Foundational: Configure a meter provider to export metrics to a local Grafana instance every 10 seconds" difficulty="beginner" >}} +{{< /clients-example >}} + +## Configure the Redis client + +You configure the client library for OTel only once per application. This will +enable OTel for all Redis connections you create. The example below shows the +options you can pass to the observability instance via the `redisotel.Config` object +during initialization. + +{{< clients-example set="observability" step="client_config" lang_filter="Go" description="Foundational: Configure Redis observability with a list of metric groups and optional command filtering and privacy controls" difficulty="beginner" >}} +{{< /clients-example >}} + +The available option methods for `redisotel.Config` are described in the table below. + +| Method | Type | Description | +| --- | --- | --- | +| `WithEnabled` | `bool` | Enable or disable OTel instrumentation. Default is `false`, so you must enable it explicitly. | +| `WithMetricGroups` | `MetricGroupFlag` | Bitmap of metric groups to enable. By default, only `MetricGroupFlagConnectionBasic` and `MetricGroupFlagResiliency` are enabled. See [Redis metric groups]({{< relref "/develop/clients/observability#redis-metric-groups" >}}) for a list of available groups. | +| `WithIncludeCommands` | `[]string` | List of Redis commands to track. If set, only these commands will be tracked. Note that you should use the Redis command name rather than the Go method name (for example `LPOP` rather than `LPopCount`). | +| `WithExcludeCommands` | `[]string` | List of Redis commands to exclude from tracking. If set, all commands except these will be tracked. Note that you should use the Redis command name rather than the Go method name (for example `LPOP` rather than `LPopCount`). | +| `WithHidePubSubChannelNames` | `bool` | If true, channel names in pub/sub metrics will be hidden. | +| `WithHideStreamNames` | `bool` | If true, stream names in streaming metrics will be hidden. | +| `WithHistogramBuckets` | `[]float64` | List of bucket boundaries for the histogram metrics. See [Custom histogram buckets](#custom-histogram-buckets) below for more information. | + +### Custom histogram buckets + +For the histogram metrics, a reasonable default set of buckets is defined, but +you can customize the bucket boundaries to suit your needs (the buckets are the +ranges of data values counted for each bar of the histogram). Pass an increasing +list of float values to the `WithHistogramBuckets` option when you create the `redisotel.Config` +object. The first and last values in the list are the lower and upper bounds of the +histogram, respectively, and the values in between define the bucket boundaries. + +## Use Redis + +Once you have configured the client, all Redis connections you create will be +automatically instrumented and the collected metrics will be exported to your +configured destination. + +The example below shows the simplest Redis connection and a few commands, +but see the +[observability demonstration repository](https://github.com/redis-developer/redis-client-observability) +for an example that calls a variety of commands in a more realistic way. + +{{< clients-example set="observability" step="use_redis" lang_filter="Go" description="Foundational: Use Redis with automatic instrumentation" difficulty="beginner" >}} +{{< /clients-example >}} + +## Shutdown + +When your application exits, you should close the Redis connection and then +you should call the `Shutdown()` method on the +`ObservabilityInstance` and `MeterProvider` instances to ensure that all pending +metrics are exported. You may find it useful to put the shutdown code in a +`defer` statement to ensure that it is called even if the main function +exits early due to an error. + +{{< clients-example set="observability" step="shutdown" lang_filter="Go" description="Foundational: Shutdown Redis observability" difficulty="beginner" >}} +{{< /clients-example >}} diff --git a/content/develop/clients/go/produsage.md b/content/develop/clients/go/produsage.md index e8aad0e764..687da074f6 100644 --- a/content/develop/clients/go/produsage.md +++ b/content/develop/clients/go/produsage.md @@ -72,7 +72,7 @@ more detailed discussion of error handling approaches in `go-redis`. `go-redis` supports [OpenTelemetry](https://opentelemetry.io/). This lets you trace command execution and monitor your server's performance. You can use this information to detect problems before they are reported -by users. See [Observability]({{< relref "/develop/clients/go#observability" >}}) +by users. See [Observability]({{< relref "/develop/clients/go/observability" >}}) for more information. ### Retries diff --git a/content/develop/clients/observability.md b/content/develop/clients/observability.md new file mode 100644 index 0000000000..ee8e0f2fff --- /dev/null +++ b/content/develop/clients/observability.md @@ -0,0 +1,165 @@ +--- +categories: +- docs +- develop +- stack +- oss +- rs +- rc +- oss +- kubernetes +- clients +description: Monitor your client's activity for optimization and debugging. +linkTitle: Observability +title: Observability +scope: overview +relatedPages: +- /develop/clients/redis-py/produsage +- /develop/clients/nodejs/produsage +- /develop/clients/go/produsage +topics: +- observability +- monitoring +- performance +- metrics +- logging +- tracing +weight: 60 +--- + +Some Redis client libraries implement the [OpenTelemetry](https://opentelemetry.io/) (OTel) +observability framework to let you gather performance metrics +for your application. This can help you optimize performance and pinpoint problems +quickly. Currently, the following clients support OTel: + +- [redis-py]({{< relref "/develop/clients/redis-py/observability" >}}) +- [go-redis]({{< relref "/develop/clients/go/observability" >}}) + + + +## Metrics overview + +Metrics are quantitative measurements of the behavior of your application. They +provide information such as how often a certain operation occurs, how long it +takes to complete, or how many errors have occurred. By analyzing these metrics, +you can identify performance bottlenecks, errors, and other issues that need to +be addressed. + +## Redis metric groups + +In Redis clients, the metrics collected by OTel are organized into the following +metric groups: + +- [`resiliency`](#group-resiliency): data related to the availability and health of the Redis connection. +- [`connection-basic`](#group-connection-basic): minimal metrics about Redis connections made by the client. +- [`connection-advanced`](#group-connection-advanced): more detailed metrics about Redis connections. +- [`command`](#group-command): metrics about Redis commands executed by the client. +- [`client-side-caching`](#group-client-side-caching): metrics about + [client-side caching]({{< relref "/develop/clients/client-side-caching" >}}) operations. +- [`streaming`](#group-streaming): metrics about + [stream]({{< relref "/develop/data-types/streams" >}}) operations. +- [`pubsub`](#group-pubsub): metrics about + [pub/sub]({{< relref "/develop/pubsub" >}}) operations. + +When you configure the client to activate OTel, you can select which metric groups +you are interested in, although all metrics in the group will be collected even +if you don't use them. The metrics in each group are described in the +[Metrics reference](#metrics-reference) below. + +## Record and visualize metrics + +You can use a monitoring tool (such as [Grafana](https://grafana.com/)) to record and +visualize the metrics collected by OTel. This provides a target endpoint +that you specify when you configure the client in your application. The tool then +collects the metrics from your application transparently as it runs. When you +have collected the data, you can visualize it using the tool's dashboarding +and graphing features. + +The [Redis client observability demonstration](https://github.com/redis-developer/redis-client-observability) +on GitHub contains examples showing how to set up a local Grafana instance, then +connect it to a Redis client and visualize the metric data as it arrives. + +## Metrics reference + +The metric groups and the metrics they contain are described below. The +name in parentheses after each group name is the group's identifier, which you +use when you configure the client to select which metrics to collect. + +The metrics contain *attributes* that provide extra information (such as +the client library and server details) that you can use to filter and +aggregate the data. The attributes are described in the Attributes +section following the metric groups. The badge shown after the attribute +name can be any of the following: + +- `required`: This attribute will always be present in the metrics. +- `optional`: This attribute may be present in the metrics. +- `conditionally required`: This attribute will be present in the metrics only if a certain condition is met, + such as when a specific error occurs. The condition is described in the attribute description. +- `recommended`: Specific client libraries may not support this attribute in some situations. + +{{< otel-metric-groups >}} diff --git a/content/develop/clients/patterns/_index.md b/content/develop/clients/patterns/_index.md index ea1ec90d54..614ce3ed2e 100644 --- a/content/develop/clients/patterns/_index.md +++ b/content/develop/clients/patterns/_index.md @@ -13,7 +13,7 @@ description: Novel patterns for working with Redis data structures linkTitle: Coding patterns title: Coding patterns aliases: /develop/use/patterns -weight: 60 +weight: 100 --- The following documents describe some novel development patterns you can use with Redis. diff --git a/content/develop/clients/redis-py/observability.md b/content/develop/clients/redis-py/observability.md new file mode 100644 index 0000000000..0e1a93fba6 --- /dev/null +++ b/content/develop/clients/redis-py/observability.md @@ -0,0 +1,111 @@ +--- +categories: +- docs +- develop +- stack +- oss +- rs +- rc +- oss +- kubernetes +- clients +description: Monitor your client's activity for optimization and debugging. +linkTitle: Observability +title: Observability +weight: 75 +--- + +`redis-py` has built-in support for [OpenTelemetry](https://opentelemetry.io/) (OTel) +instrumentation to collect metrics. This can be very helpful for +diagnosing problems and improving the performance and connection resiliency of +your application. See the +[Observability overview]({{< relref "/develop/clients/observability" >}}) +for an introduction to Redis client observability and a reference guide for the +available metrics. + +This page explains how to enable and use OTel instrumentation +in `redis-py` using an example configuration for a local [Grafana](https://grafana.com/) +instance. See our +[observability demonstration repository](https://github.com/redis-developer/redis-client-observability) +on GitHub to learn how to set up a suitable Grafana dashboard. + +## Installation + +Install OTel support for `redis-py` with the following command: + +```bash +pip install redis[otel] +``` + +## Import + +Start by importing the required OTel and Redis modules: + +{{< clients-example set="observability" step="import" lang_filter="Python" description="Foundational: Import required libraries for Redis observability, OpenTelemetry metrics, and Redis operations" difficulty="beginner" >}} +{{< /clients-example >}} + +## Configure the meter provider + +Otel uses a [Meter provider](https://opentelemetry.io/docs/concepts/signals/metrics/#meter-provider) +to create the objects that collect the metric information. The example below +configures a meter provider to export metrics to a local Grafana instance +every 10 seconds, but see the [OpenTelemetry Python docs](https://opentelemetry.io/docs/languages/python/) +to learn more about other export options. + +{{< clients-example set="observability" step="setup_meter_provider" lang_filter="Python" description="Foundational: Configure a meter provider to export metrics to a local Grafana instance every 10 seconds" difficulty="beginner" >}} +{{< /clients-example >}} + +## Configure the Redis client + +You configure the client library for OTel only once per application. This will +enable OTel for all Redis connections you create. The example below shows the +options you can pass to the observability instance via the `OTelConfig` object +during initialization. + +{{< clients-example set="observability" step="client_config" lang_filter="Python" description="Foundational: Configure Redis observability with a list of metric groups and optional command filtering and privacy controls" difficulty="beginner" >}} +{{< /clients-example >}} + +The available options for `OTelConfig` are described in the table below: + +| Option | Type | Description | +| --- | --- | --- | +| `metric_groups` | `List[MetricGroup]` | List of metric groups to enable. By default, only `CONNECTION_BASIC` and `RESILIENCY` are enabled. See [Redis metric groups]({{< relref "/develop/clients/observability#redis-metric-groups" >}}) for a list of available groups. | +| `include_commands` | `List[str]` | List of Redis commands to track. If set, only these commands will be tracked. Note that you should use the Redis command name rather than the Python method name where the two differ. | +| `exclude_commands` | `List[str]` | List of Redis commands to exclude from tracking. If set, all commands except these will be tracked. Note that you should use the Redis command name rather than the Python method name where the two differ. | +| `hide_pubsub_channel_names` | `bool` | If true, channel names in pub/sub metrics will be hidden. | +| `hide_stream_names` | `bool` | If true, stream names in streaming metrics will be hidden. | +| `buckets_operation_duration` | `List[float]` | List of bucket boundaries for the [`operation.duration`]({{< relref "/develop/clients/observability/#metric-redis.client.db.client.operation.duration" >}}) histogram (see [Custom histogram buckets](#custom-histogram-buckets) below). | +| `buckets_stream_processing_duration` | `List[float]` | List of bucket boundaries for the [`stream.processing.duration`]({{< relref "/develop/clients/observability/#metric-redis.client.db.client.stream.processing.duration" >}}) histogram (see [Custom histogram buckets](#custom-histogram-buckets) below). | +| `buckets_connection_create_time` | `List[float]` | List of bucket boundaries for the [`connection.create.time`]({{< relref "/develop/clients/observability/#metric-redis.client.db.client.connection.create.time" >}}) histogram (see [Custom histogram buckets](#custom-histogram-buckets) below). | +| `buckets_connection_wait_time` | `List[float]` | List of bucket boundaries for the [`connection.wait.time`]({{< relref "/develop/clients/observability/#metric-redis.client.db.client.connection.wait.time" >}}) histogram (see [Custom histogram buckets](#custom-histogram-buckets) below). | + +### Custom histogram buckets + +For the histogram metrics, a reasonable default set of buckets is defined, but +you can customize the bucket boundaries to suit your needs (the buckets are the +ranges of data values counted for each bar of the histogram). Pass an increasing +list of float values to the `buckets_xxx` options when you create the `OTelConfig` +object. The first and last values in the list are the lower and upper bounds of the +histogram, respectively, and the values in between define the bucket boundaries. + +## Use Redis + +Once you have configured the client, all Redis connections you create will be +automatically instrumented and the collected metrics will be exported to your +configured destination. + +The example below shows the simplest Redis connection and a few commands, +but see the +[observability demonstration repository](https://github.com/redis-developer/redis-client-observability) +for an example that calls a variety of commands in a more realistic way. + +{{< clients-example set="observability" step="use_redis" lang_filter="Python" description="Foundational: Use Redis with automatic instrumentation" difficulty="beginner" >}} +{{< /clients-example >}} + +## Shutdown + +When your application exits, you should call the `shutdown()` method to ensure +that all pending metrics are exported. + +{{< clients-example set="observability" step="shutdown" lang_filter="Python" description="Foundational: Shutdown Redis observability" difficulty="beginner" >}} +{{< /clients-example >}} diff --git a/content/develop/clients/redis-py/produsage.md b/content/develop/clients/redis-py/produsage.md index 53d41c9ff9..c43aa07ab1 100644 --- a/content/develop/clients/redis-py/produsage.md +++ b/content/develop/clients/redis-py/produsage.md @@ -31,6 +31,7 @@ progress in implementing the recommendations. - [ ] [Exception handling](#exception-handling) - [ ] [Timeouts](#timeouts) - [ ] [Smart client handoffs](#smart-client-handoffs) +- [ ] [Monitor performance and errors](#monitor-performance-and-errors) ``` ## Recommendations @@ -210,3 +211,11 @@ See [Smart client handoffs]({{< relref "/develop/clients/sch" >}}) for more information about SCH and [Connect using Smart client handoffs]({{< relref "/develop/clients/redis-py/connect#connect-using-smart-client-handoffs-sch" >}}) for example code. + +### Monitor performance and errors + +`redis-py` supports [OpenTelemetry](https://opentelemetry.io/). This lets +you trace command execution and monitor your server's performance. +You can use this information to detect problems before they are reported +by users. See [Observability]({{< relref "/develop/clients/redis-py/observability" >}}) +for more information. \ No newline at end of file diff --git a/data/client_obs_metrics.json b/data/client_obs_metrics.json new file mode 100644 index 0000000000..bd295959fa --- /dev/null +++ b/data/client_obs_metrics.json @@ -0,0 +1,1169 @@ +{ + "$schema": "https://redis.io/schemas/client-observability-metrics-v0.2.json", + "spec_version": "0.2", + "namespace_default": "redis.client", + "metric_groups": [ + { + "id": "resiliency", + "title": "Resiliency & cluster specific metrics", + "metrics": [ + { + "name": "redis.client.errors", + "type": "Counter", + "unit": { + "symbol": "{error}", + "semantic": "errors" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "server.address", + "cardinality": "recommended" + }, + { + "ref": "server.port", + "cardinality": "recommended" + }, + { + "ref": "network.peer.address", + "cardinality": "optional" + }, + { + "ref": "network.peer.port", + "cardinality": "optional" + }, + { + "ref": "error.type", + "cardinality": "required" + }, + { + "ref": "redis.client.errors.category", + "cardinality": "required" + }, + { + "ref": "db.response.status_code", + "cardinality": "optional" + }, + { + "ref": "redis.client.errors.internal", + "cardinality": "required" + }, + { + "ref": "redis.client.operation.retry_attempts", + "cardinality": "required" + } + ], + "description": "A counter of all errors (both returned to the user and handled internally in the client library)", + "otel": { + "instrument_kind": "counter", + "monotonic": true, + "async": false + } + }, + { + "name": "redis.client.maintenance.notifications", + "type": "Counter", + "unit": { + "symbol": "{notification}", + "semantic": "notifications" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "server.address", + "cardinality": "recommended" + }, + { + "ref": "server.port", + "cardinality": "recommended" + }, + { + "ref": "network.peer.address", + "cardinality": "optional" + }, + { + "ref": "network.peer.port", + "cardinality": "optional" + }, + { + "ref": "redis.client.connection.notification", + "cardinality": "optional" + } + ], + "description": "Tracks server-side maintenance notifications", + "otel": { + "instrument_kind": "counter", + "monotonic": true, + "async": false + } + }, + { + "name": "redis.client.geofailover.failovers", + "type": "Counter", + "unit": { + "symbol": "{failover}", + "semantic": "failovers" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.client.geofailover.reason", + "cardinality": "required" + }, + { + "ref": "db.client.geofailover.fail_from", + "cardinality": "required" + }, + { + "ref": "db.client.geofailover.fail_to", + "cardinality": "required" + } + ], + "description": "Tracks client-side geographic failovers", + "otel": { + "instrument_kind": "counter", + "monotonic": true, + "async": false + } + } + ] + }, + { + "id": "command", + "title": "Command metrics", + "metrics": [ + { + "name": "db.client.operation.duration", + "type": "Histogram", + "unit": { + "symbol": "s", + "semantic": "seconds" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.namespace", + "cardinality": "recommended" + }, + { + "ref": "db.operation.name", + "cardinality": "recommended" + }, + { + "ref": "db.response.status_code", + "cardinality": "optional" + }, + { + "ref": "error.type", + "cardinality": "optional" + }, + { + "ref": "redis.client.errors.category", + "cardinality": "optional" + }, + { + "ref": "server.address", + "cardinality": "recommended" + }, + { + "ref": "server.port", + "cardinality": "recommended" + }, + { + "ref": "network.peer.address", + "cardinality": "optional" + }, + { + "ref": "network.peer.port", + "cardinality": "optional" + }, + { + "ref": "redis.client.operation.retry_attempts", + "cardinality": "required" + } + ], + "description": "Command duration from the application's point of view (includes retries)", + "otel": { + "instrument_kind": "histogram", + "monotonic": false, + "async": false, + "aggregation": "explicit_bucket" + } + } + ] + }, + { + "id": "connection-basic", + "title": "Connection metrics (basic)", + "metrics": [ + { + "name": "db.client.connection.count", + "type": "Gauge", + "observable": true, + "unit": { + "symbol": "{connection}", + "semantic": "connections" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.client.connection.pool.name", + "cardinality": "required" + }, + { + "ref": "db.client.connection.state", + "cardinality": "optional" + }, + { + "ref": "redis.client.connection.pubsub", + "cardinality": "optional" + } + ], + "description": "Current connections by state (idle/used). Sum by state for “live connections”. This is an async(observable) metric.", + "otel": { + "instrument_kind": "observable_gauge", + "monotonic": false, + "async": true + } + }, + { + "name": "db.client.connection.create_time", + "type": "Histogram", + "unit": { + "symbol": "s", + "semantic": "seconds" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.client.connection.pool.name", + "cardinality": "required" + } + ], + "description": "The time it took to create a new connection, including handshake", + "otel": { + "instrument_kind": "histogram", + "monotonic": false, + "async": false, + "aggregation": "explicit_bucket" + } + }, + { + "name": "redis.client.connection.relaxed_timeout", + "type": "UpDownCounter", + "unit": { + "symbol": "{relaxation}", + "semantic": "relaxations" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.client.connection.pool.name", + "cardinality": "required" + }, + { + "ref": "redis.client.connection.notification", + "cardinality": "optional" + } + ], + "description": "How many times the connection timeout has been increased/decreased (after a server maintenance notification). Counts up for relaxed timeout, counts down for unrelaxed timeout", + "otel": { + "instrument_kind": "updowncounter", + "monotonic": false, + "async": false + } + }, + { + "name": "redis.client.connection.handoff", + "type": "Counter", + "unit": { + "symbol": "1", + "semantic": "handoffs" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.client.connection.pool.name", + "cardinality": "required" + } + ], + "description": "Connections that have been handed off to another node (e.g after a MOVING notification)", + "otel": { + "instrument_kind": "counter", + "monotonic": true, + "async": false + } + } + ] + }, + { + "id": "connection-advanced", + "title": "Connection metrics (advanced)", + "metrics": [ + { + "name": "db.client.connection.pending_requests", + "type": "Gauge", + "observable": true, + "unit": { + "symbol": "{request}", + "semantic": "requests" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.client.connection.pool.name", + "cardinality": "required" + } + ], + "description": "The number of current pending requests for an open connection. This is an async (observable) metric.", + "otel": { + "instrument_kind": "observable_gauge", + "monotonic": false, + "async": true + } + }, + { + "name": "db.client.connection.wait_time", + "type": "Histogram", + "unit": { + "symbol": "s", + "semantic": "seconds" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.client.connection.pool.name", + "cardinality": "required" + } + ], + "description": "The time it took to obtain an open connection from the pool. Only reported for clients that wait until a new connection becomes available.", + "otel": { + "instrument_kind": "histogram", + "monotonic": false, + "async": false, + "aggregation": "explicit_bucket" + } + }, + { + "name": "redis.client.connection.closed", + "type": "Counter", + "unit": { + "symbol": "{connection}", + "semantic": "connections" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.client.connection.pool.name", + "cardinality": "required" + }, + { + "ref": "redis.client.connection.close.reason", + "cardinality": "optional" + }, + { + "ref": "error.type", + "cardinality": "optional" + }, + { + "ref": "redis.client.errors.category", + "cardinality": "optional" + } + ], + "description": "Total number of closed connections (tcp sockets)", + "otel": { + "instrument_kind": "counter", + "monotonic": true, + "async": false + } + } + ] + }, + { + "id": "client-side-caching", + "title": "Client-Side caching metrics", + "metrics": [ + { + "name": "redis.client.csc.requests", + "type": "Counter", + "unit": { + "symbol": "{request}", + "semantic": "requests" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.namespace", + "cardinality": "recommended" + }, + { + "ref": "redis.client.csc.result", + "cardinality": "optional" + } + ], + "description": "The total number of requests to the cache. Used to calculate the hit ratio (hits / total).", + "otel": { + "instrument_kind": "counter", + "monotonic": true, + "async": false + } + }, + { + "name": "redis.client.csc.items", + "type": "Gauge", + "observable": true, + "unit": { + "symbol": "{item}", + "semantic": "items" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "db.client.connection.pool.name", + "cardinality": "required" + } + ], + "description": "The total number of cached responses currently stored. This is an async (observable) metric.", + "otel": { + "instrument_kind": "observable_gauge", + "monotonic": false, + "async": true + } + }, + { + "name": "redis.client.csc.evictions", + "type": "Counter", + "unit": { + "symbol": "{eviction}", + "semantic": "evictions" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "redis.client.csc.reason", + "cardinality": "optional" + } + ], + "description": "The total number of items evicted from the cache due to size limits or server invalidation signals.", + "otel": { + "instrument_kind": "counter", + "monotonic": true, + "async": false + } + }, + { + "name": "redis.client.csc.network_saved", + "type": "Counter", + "unit": { + "symbol": "By", + "semantic": "bytes" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + } + ], + "description": "The total number of bytes saved by serving responses from the cache instead of the network.", + "otel": { + "instrument_kind": "counter", + "monotonic": true, + "async": false + } + } + ] + }, + { + "id": "pubsub", + "title": "PubSub metrics", + "metrics": [ + { + "name": "redis.client.pubsub.messages", + "type": "Counter", + "unit": { + "symbol": "{message}", + "semantic": "messages" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "redis.client.pubsub.message.direction", + "cardinality": "optional" + }, + { + "ref": "redis.client.pubsub.channel", + "cardinality": "optional" + }, + { + "ref": "redis.client.pubsub.sharded", + "cardinality": "optional" + } + ], + "description": "Tracks published and received messages.", + "otel": { + "instrument_kind": "counter", + "monotonic": true, + "async": false + } + } + ] + }, + { + "id": "streaming", + "title": "Stream metrics", + "metrics": [ + { + "name": "redis.client.stream.lag", + "type": "Histogram", + "unit": { + "symbol": "s", + "semantic": "seconds" + }, + "attributes": [ + { + "ref": "redis.client.library", + "cardinality": "required" + }, + { + "ref": "db.system.name", + "cardinality": "required" + }, + { + "ref": "redis.client.stream.name", + "cardinality": "optional" + }, + { + "ref": "redis.client.stream.consumer_group", + "cardinality": "optional" + }, + { + "ref": "redis.client.consumer_name", + "cardinality": "optional" + } + ], + "description": "End-to-end lag per message, showing how stale messages are when the application starts processing them. Can be computed from stream message_id timestamp vs current time.", + "otel": { + "instrument_kind": "histogram", + "monotonic": false, + "async": false, + "aggregation": "explicit_bucket" + } + } + ] + } + ], + "attributes_definitions": { + "tables": [ + { + "id": "semconv_defined_attributes", + "title": "SemConv defined attributes", + "attributes": [ + { + "attribute": "db.system.name", + "type": "string", + "description": "Always set to `redis`", + "examples": "redis", + "requirement_level": "required" + }, + { + "attribute": "db.operation.name", + "type": "string", + "description": "The name of the operation being executed.", + "examples": "GET; SET; HGETALL", + "requirement_level": "required" + }, + { + "attribute": "db.namespace", + "type": "string", + "description": "The name of the database namespace. For Redis, this is the database number.", + "examples": "0; 1; 2", + "requirement_level": "recommended" + }, + { + "attribute": "db.response.status_code", + "type": "string", + "description": "The Redis error prefix.", + "examples": "OK; WRONGTYPE", + "requirement_level": "conditionally_required", + "requirement_condition": "if the operation has failed and the status code is available" + }, + { + "attribute": "error.type", + "type": "string", + "description": "Class of error the operation ended with", + "examples": "java.net.UnknownHostException, server_certificate_invalid, ClusterCrossSlotError", + "requirement_level": "conditionally_required", + "requirement_condition": "if and only if the operation has failed" + }, + { + "attribute": "db.operation.batch.size", + "type": "int", + "description": "The number of queries included in a batch operation.", + "examples": "2; 3; 4", + "requirement_level": "conditionally_required", + "requirement_condition": "the operation is a pipeline or a transaction" + }, + { + "attribute": "db.stored_procedure.name", + "type": "string", + "description": "The name or sha1 digest of a Lua script in the database.", + "examples": "GetCustomer", + "requirement_level": "recommended", + "requirement_condition": "if operation applies to a specific Lua script" + }, + { + "attribute": "network.peer.address", + "type": "string", + "description": "Peer address of the database node where the operation was performed.", + "examples": "10.1.2.80; /tmp/my.sock", + "requirement_level": "recommended" + }, + { + "attribute": "network.peer.port", + "type": "int", + "description": "Peer port number of the network connection.", + "examples": "65123", + "requirement_level": "recommended", + "requirement_condition": "if and only if network.peer.address is set" + }, + { + "attribute": "server.address", + "type": "string", + "description": "Name of the database host.", + "examples": "example.com; 10.1.2.80; /tmp/my.sock; redis-13206.aws-cluster-71165.cto.redislabs.com", + "requirement_level": "recommended" + }, + { + "attribute": "server.port", + "type": "int", + "description": "Server port number.", + "examples": "80; 8080; 443", + "requirement_level": "conditionally_required", + "requirement_condition": "using a port other than the default port for this DBMS and if server.address is set" + }, + { + "attribute": "db.client.connection.pool.name", + "type": "string", + "description": "The name of the connection pool.", + "examples": "10.1.2.8/0; mypool", + "requirement_level": "required" + }, + { + "attribute": "db.client.connection.state", + "type": "string", + "description": "The state of a connection in the pool. Possible values: idle or used", + "examples": "idle; used", + "requirement_level": "required" + }, + { + "attribute": "db.client.geofailover.fail_from", + "type": "string", + "description": "FQDN of database from which failover happened.", + "examples": "{ip}:{port}/{weight}", + "requirement_level": "required" + }, + { + "attribute": "db.client.geofailover.fail_to", + "type": "string", + "description": "FQDN of database to which failover happened.", + "examples": "{ip}:{port}/{weight}", + "requirement_level": "required" + }, + { + "attribute": "db.client.geofailover.reason", + "type": "string", + "description": "Reason for failover to happen.", + "examples": "automatic; manual", + "requirement_level": "required", + "requirement_condition": "for transactions and pipelined calls" + } + ] + }, + { + "id": "redis_defined_attributes", + "title": "Redis defined attributes", + "attributes": [ + { + "attribute": "redis.client.library", + "type": "string", + "description": "The redis client library reporting the metrics in format: :", + "examples": "redis-py:v8.0.0b1; go-redis:v9.0.0", + "requirement_level": "required" + }, + { + "attribute": "redis.client.operation.retry_attempts", + "type": "int", + "description": "Shows how many times the command has been retried.", + "examples": "0; 1; 2", + "requirement_level": "required" + }, + { + "attribute": "redis.client.errors.internal", + "type": "bool", + "description": "Specifies whether the error has been handled internally or surfaced to the user.", + "examples": "true; false", + "requirement_level": "required", + "requirement_condition": "for errors" + }, + { + "attribute": "redis.client.errors.category", + "type": "string", + "description": "Error category, to facilitate grouping and filtering of errors.", + "examples": "network (transport/DNS/socket issues); tls (security/TLS errors); auth (authentication/authorization); server (Redis server errors including cluster errors); other (uncategorized errors)", + "requirement_level": "required", + "requirement_condition": "for errors" + }, + { + "attribute": "redis.client.connection.close.reason", + "type": "string", + "description": "Reason why the connection was closed. Must be one of: application_close (app/client explicitly closed); pool_eviction_idle (idle timeout / min-idle policy); server_close (server initiated FIN/RST/hangup); error (the error itself is shown in error.type); healthcheck_failed (failed ping/heartbeat)", + "examples": "application_close; pool_eviction_idle; server_close; error; healthcheck_failed", + "requirement_level": "required" + }, + { + "attribute": "redis.client.connection.notification", + "type": "string", + "description": "Server-side maintenance notifications containing the message type.", + "examples": "MOVING; MIGRATING", + "requirement_level": "required" + }, + { + "attribute": "redis.client.connection.pubsub", + "type": "bool", + "description": "Specifies if the connection is dedicated to a pubsub subscription. Default is False", + "examples": "true; false", + "requirement_level": "required" + }, + { + "attribute": "redis.client.consumer_name", + "type": "string", + "description": "Name of the stream consumer", + "examples": "consumer1", + "requirement_level": "optional" + }, + { + "attribute": "redis.client.csc.reason", + "type": "string", + "description": "The reason why an item was evicted from the client-side cache. Must be one of: full (cache size limit reached); invalidation (received server invalidation message); ttl (local expiration)", + "examples": "full; invalidation", + "requirement_level": "required" + }, + { + "attribute": "redis.client.csc.result", + "type": "string", + "description": "Result of client-side cache lookup. Must be one of: hit (served from local cache); miss (fetched from server)", + "examples": "hit; miss", + "requirement_level": "required" + }, + { + "attribute": "redis.client.pubsub.channel", + "type": "string", + "description": "Name of the pub/sub channel", + "examples": "channel1", + "requirement_level": "conditionally_required", + "requirement_condition": "if the hide_pubsub_channel_names SDK config is disabled" + }, + { + "attribute": "redis.client.pubsub.message.direction", + "type": "string", + "description": "Determines whether a processed message is sent or received. Must be one of: out (published messages);in(received messages)", + "examples": "out; in", + "requirement_level": "required" + }, + { + "attribute": "redis.client.pubsub.sharded", + "type": "bool", + "description": "Specifies it the message was sent/received on a sharded pubsub connection", + "examples": "true; false", + "requirement_level": "optional" + }, + { + "attribute": "redis.client.stream.consumer_group", + "type": "string", + "description": "Name of the stream consumer group", + "examples": "consumer_group1", + "requirement_level": "optional" + }, + { + "attribute": "redis.client.stream.name", + "type": "string", + "description": "Name of the Redis stream", + "examples": "stream1", + "requirement_level": "conditionally_required", + "requirement_condition": "if the hide_stream_names SDK config is disabled" + } + ] + } + ], + "attribute_index": { + "db.operation.batch.size": { + "attribute": "db.operation.batch.size", + "type": "int", + "description": "The number of queries included in a batch operation.", + "examples": "2; 3; 4", + "requirement_level": "conditionally_required", + "requirement_condition": "the operation is a pipeline or a transaction", + "definition_source_table": "semconv_defined_attributes" + }, + "db.stored_procedure.name": { + "attribute": "db.stored_procedure.name", + "type": "string", + "description": "The name or sha1 digest of a Lua script in the database.", + "examples": "GetCustomer", + "requirement_level": "recommended", + "requirement_condition": "if operation applies to a specific Lua script", + "definition_source_table": "semconv_defined_attributes" + }, + "network.peer.address": { + "attribute": "network.peer.address", + "type": "string", + "description": "Peer address of the database node where the operation was performed.", + "examples": "10.1.2.80; /tmp/my.sock", + "requirement_level": "recommended", + "definition_source_table": "semconv_defined_attributes" + }, + "network.peer.port": { + "attribute": "network.peer.port", + "type": "int", + "description": "Peer port number of the network connection.", + "examples": "65123", + "requirement_level": "recommended", + "requirement_condition": "if and only if network.peer.address is set", + "definition_source_table": "semconv_defined_attributes" + }, + "server.address": { + "attribute": "server.address", + "type": "string", + "description": "Name of the database host.", + "examples": "example.com; 10.1.2.80; /tmp/my.sock; redis-13206.aws-cluster-71165.cto.redislabs.com", + "requirement_level": "recommended", + "definition_source_table": "semconv_defined_attributes" + }, + "server.port": { + "attribute": "server.port", + "type": "int", + "description": "Server port number.", + "examples": "80; 8080; 443", + "requirement_level": "conditionally_required", + "requirement_condition": "using a port other than the default port for this DBMS and if server.address is set", + "definition_source_table": "semconv_defined_attributes" + }, + "db.client.connection.pool.name": { + "attribute": "db.client.connection.pool.name", + "type": "string", + "description": "The name of the connection pool.", + "examples": "10.1.2.8/0; mypool", + "requirement_level": "required", + "definition_source_table": "semconv_defined_attributes" + }, + "db.client.connection.state": { + "attribute": "db.client.connection.state", + "type": "string", + "description": "The state of a connection in the pool. Possible values: idle or used", + "examples": "idle; used", + "requirement_level": "required", + "definition_source_table": "semconv_defined_attributes" + }, + "db.client.geofailover.fail_from": { + "attribute": "db.client.geofailover.fail_from", + "type": "string", + "description": "FQDN of database from which failover happened.", + "examples": "{ip}:{port}/{weight}", + "requirement_level": "required", + "definition_source_table": "semconv_defined_attributes" + }, + "db.client.geofailover.fail_to": { + "attribute": "db.client.geofailover.fail_to", + "type": "string", + "description": "FQDN of database to which failover happened.", + "examples": "{ip}:{port}/{weight}", + "requirement_level": "required", + "definition_source_table": "semconv_defined_attributes" + }, + "db.client.geofailover.reason": { + "attribute": "db.client.geofailover.reason", + "type": "string", + "description": "Reason for failover to happen", + "examples": "automatic; manual", + "requirement_level": "required", + "requirement_condition": "for transactions and pipelined calls", + "definition_source_table": "semconv_defined_attributes" + }, + "redis.client.library": { + "attribute": "redis.client.library", + "type": "string", + "description": "The redis client library reporting the metrics in format: :", + "examples": "redis-py:v8.0.0b1; go-redis:v9.0.0", + "requirement_level": "required", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.operation.retry_attempts": { + "attribute": "redis.client.operation.retry_attempts", + "type": "int", + "description": "Shows how many times the command has been retried.", + "examples": "0; 1; 2", + "requirement_level": "required", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.errors.internal": { + "attribute": "redis.client.errors.internal", + "type": "bool", + "description": "Specifies whether the error has been handled internally or surfaced to the user.", + "examples": "true; false", + "requirement_level": "required", + "requirement_condition": "for errors", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.errors.category": { + "attribute": "redis.client.errors.category", + "type": "string", + "description": "Error category, to facilitate grouping and filtering of errors.", + "examples": "network (transport/DNS/socket issues); tls (security/TLS errors); auth (authentication/authorization); server (Redis server errors including cluster errors); other (uncategorized errors)", + "requirement_level": "required", + "requirement_condition": "for errors", + "definition_source_table": "redis_defined_attributes" + }, + "db.system.name": { + "attribute": "db.system.name", + "type": "string", + "description": "Always set to `redis`", + "examples": "redis", + "requirement_level": "required", + "definition_source_table": "semconv_defined_attributes" + }, + "db.operation.name": { + "attribute": "db.operation.name", + "type": "string", + "description": "The name of the operation being executed.", + "examples": "GET; SET; HGETALL", + "requirement_level": "required", + "definition_source_table": "semconv_defined_attributes" + }, + "db.namespace": { + "attribute": "db.namespace", + "type": "string", + "description": "The name of the database namespace. For Redis, this is the database number.", + "examples": "0; 1; 2", + "requirement_level": "recommended", + "definition_source_table": "semconv_defined_attributes" + }, + "db.response.status_code": { + "attribute": "db.response.status_code", + "type": "string", + "description": "The Redis error prefix.", + "examples": "OK; WRONGTYPE", + "requirement_level": "conditionally_required", + "requirement_condition": "if the operation has failed and the status code is available", + "definition_source_table": "semconv_defined_attributes" + }, + "error.type": { + "attribute": "error.type", + "type": "string", + "description": "Class of error the operation ended with", + "examples": "java.net.UnknownHostException, server_certificate_invalid, ClusterCrossSlotError", + "requirement_level": "conditionally_required", + "requirement_condition": "if and only if the operation has failed", + "definition_source_table": "semconv_defined_attributes" + }, + "redis.client.connection.close.reason": { + "attribute": "redis.client.connection.close.reason", + "type": "string", + "description": "Reason why the connection was closed. Must be one of: application_close (app/client explicitly closed); pool_eviction_idle (idle timeout / min-idle policy); server_close (server initiated FIN/RST/hangup); error (the error itself is shown in error.type); healthcheck_failed (failed ping/heartbeat)", + "examples": "application_close; pool_eviction_idle; server_close; error; healthcheck_failed", + "requirement_level": "required", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.connection.notification": { + "attribute": "redis.client.connection.notification", + "type": "string", + "description": "Server-side maintenance notifications containing the message type.", + "examples": "MOVING; MIGRATING", + "requirement_level": "required", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.connection.pubsub": { + "attribute": "redis.client.connection.pubsub", + "type": "bool", + "description": "Specifies if the connection is dedicated to a pubsub subscription. Default is False", + "examples": "true; false", + "requirement_level": "required", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.consumer_name": { + "attribute": "redis.client.consumer_name", + "type": "string", + "description": "Name of the stream consumer", + "examples": "consumer1", + "requirement_level": "optional", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.csc.reason": { + "attribute": "redis.client.csc.reason", + "type": "string", + "description": "The reason why an item was evicted from the client-side cache. Must be one of: full (cache size limit reached); invalidation (received server invalidation message); ttl (local expiration)", + "examples": "full; invalidation", + "requirement_level": "required", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.csc.result": { + "attribute": "redis.client.csc.result", + "type": "string", + "description": "Result of client-side cache lookup. Must be one of: hit (served from local cache); miss (fetched from server)", + "examples": "hit; miss", + "requirement_level": "required", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.pubsub.channel": { + "attribute": "redis.client.pubsub.channel", + "type": "string", + "description": "Name of the pub/sub channel", + "examples": "channel1", + "requirement_level": "conditionally_required", + "requirement_condition": "if the hide_pubsub_channel_names SDK config is disabled", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.pubsub.message.direction": { + "attribute": "redis.client.pubsub.message.direction", + "type": "string", + "description": "Determines whether a processed message is sent or received. Must be one of: out (published messages);in(received messages)", + "examples": "out; in", + "requirement_level": "required", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.pubsub.sharded": { + "attribute": "redis.client.pubsub.sharded", + "type": "bool", + "description": "Specifies it the message was sent/received on a sharded pubsub connection", + "examples": "true; false", + "requirement_level": "optional", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.stream.consumer_group": { + "attribute": "redis.client.stream.consumer_group", + "type": "string", + "description": "Name of the stream consumer group", + "examples": "consumer_group1", + "requirement_level": "optional", + "definition_source_table": "redis_defined_attributes" + }, + "redis.client.stream.name": { + "attribute": "redis.client.stream.name", + "type": "string", + "description": "Name of the Redis stream", + "examples": "stream1", + "requirement_level": "conditionally_required", + "requirement_condition": "if the hide_stream_names SDK config is disabled", + "definition_source_table": "redis_defined_attributes" + } + } + } +} \ No newline at end of file diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html index 728b8d086e..6e8ee9aa69 100644 --- a/layouts/_default/baseof.html +++ b/layouts/_default/baseof.html @@ -35,6 +35,10 @@ {{ partial "scss.html" (dict "path" "scss/style.scss") }} {{ partialCached "css.html" . }} + {{ if .Page.Store.Get "hasOtelMetrics" }} + + {{ end }} + {{ block "head" . }}{{ end }} {{ partial "ai-metadata.html" . }} @@ -115,6 +119,11 @@ {{ end }} + + {{ if .Page.Store.Get "hasOtelMetrics" }} + + {{ end }} + {{ if .Page.Store.Get "hasTimeline" }} diff --git a/layouts/shortcodes/otel-metric-groups.html b/layouts/shortcodes/otel-metric-groups.html new file mode 100644 index 0000000000..af3875ccf7 --- /dev/null +++ b/layouts/shortcodes/otel-metric-groups.html @@ -0,0 +1,13 @@ +{{- /* Load the JSON data from data/client_obs_metrics.json */ -}} +{{- $jsonPath := "data/client_obs_metrics.json" -}} +{{- $jsonContent := os.ReadFile $jsonPath -}} +{{- $jsonData := unmarshal $jsonContent -}} + +{{- /* Preserve JSON in
 for AI agents and non-JS users */ -}}
+
+{{- $jsonData | jsonify (dict "indent" "  ") -}}
+
+ +{{- /* Set page store flag for conditional JavaScript loading (future use) */ -}} +{{- .Page.Store.Set "hasOtelMetrics" true -}} + diff --git a/local_examples/client-specific/go/observability.go b/local_examples/client-specific/go/observability.go new file mode 100644 index 0000000000..c1e9e65673 --- /dev/null +++ b/local_examples/client-specific/go/observability.go @@ -0,0 +1,132 @@ +// EXAMPLE: observability +// REMOVE_START +package main + +// REMOVE_END + +// STEP_START import +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/extra/redisotel-native/v9" + "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" +) + +// STEP_END + +func main() { + // STEP_START setup_meter_provider + ctx := context.Background() + + // Create OTLP exporter that sends metrics to the collector + // Default endpoint is localhost:4317 (gRPC) + exporter, err := otlpmetricgrpc.New(ctx, + otlpmetricgrpc.WithInsecure(), // Use insecure for local development + // For production, configure TLS and authentication: + // otlpmetricgrpc.WithEndpoint("your-collector:4317"), + // otlpmetricgrpc.WithTLSCredentials(...), + ) + + if err != nil { + panic(err) + } + + // Create resource with service name + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName( + fmt.Sprintf("go-redis-examples:%d", time.Now().Unix()), + ), + ), + ) + + if err != nil { + panic(err) + } + + // Create meter provider with periodic reader + // Metrics are exported every 10 seconds + meterProvider := metric.NewMeterProvider( + metric.WithResource(res), + metric.WithReader( + metric.NewPeriodicReader(exporter, + metric.WithInterval(10*time.Second), + ), + ), + ) + + // Set the global meter provider + otel.SetMeterProvider(meterProvider) + // STEP_END + + // STEP_START client_config + // Initialize OTel instrumentation BEFORE creating Redis clients + otelInstance := redisotel.GetObservabilityInstance() + config := redisotel.NewConfig(). + // You must enable OTel explicitly + WithEnabled(true). + // Enable the metric groups you want to collect. Use bitwise OR + // to combine multiple groups. The group `MetricGroupFlagAll` + // includes all groups. + WithMetricGroups(redisotel.MetricGroupFlagCommand | + redisotel.MetricGroupFlagConnectionBasic | + redisotel.MetricGroupFlagResiliency | + redisotel.MetricGroupFlagConnectionAdvanced | + redisotel.MetricGroupFlagPubSub | + redisotel.MetricGroupFlagStream). + // Filter which commands to track + WithIncludeCommands([]string{"GET", "SET"}). + WithExcludeCommands([]string{"DEBUG", "SLOWLOG"}). + // Privacy controls + WithHidePubSubChannelNames(true). + WithHideStreamNames(true). + // Custom histogram buckets + WithHistogramBuckets([]float64{ + 0.0001, // 0.1ms + 0.0005, // 0.5ms + 0.001, // 1ms + 0.005, // 5ms + 0.01, // 10ms + 0.05, // 50ms + 0.1, // 100ms + 0.5, // 500ms + 1.0, // 1s + 5.0, // 5s + 10.0, // 10s + }) + + if err := otelInstance.Init(config); err != nil { + panic(err) + } + // STEP_END + + // STEP_START use_redis + rdb := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password set + DB: 0, // use default DB + }) + + rdb.Set(ctx, "key", "value", 0) + v, err := rdb.Get(ctx, "key").Result() + + if err != nil { + panic(err) + } + + fmt.Println(v) + // STEP_END + + // STEP_START shutdown + rdb.Close() + otelInstance.Shutdown() + meterProvider.Shutdown(context.Background()) + // STEP_END +} diff --git a/local_examples/client-specific/redis-py/observability.py b/local_examples/client-specific/redis-py/observability.py new file mode 100644 index 0000000000..3a7d6ac5c0 --- /dev/null +++ b/local_examples/client-specific/redis-py/observability.py @@ -0,0 +1,75 @@ +# EXAMPLE: observability +# STEP_START import +# OpenTelemetry metrics API +from opentelemetry import metrics +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter + +# Redis observability API +from redis.observability.providers import get_observability_instance +from redis.observability.config import OTelConfig, MetricGroup + +# Redis client +import redis +# STEP_END + +# STEP_START setup_meter_provider +exporter = OTLPMetricExporter(endpoint="http://localhost:4318/v1/metrics") +reader = PeriodicExportingMetricReader(exporter=exporter, export_interval_millis=10000) +provider = MeterProvider(metric_readers=[reader]) +metrics.set_meter_provider(provider) +# STEP_END + +# STEP_START client_config +otel = get_observability_instance() +otel.init(OTelConfig( + # Metric groups to enable (default: CONNECTION_BASIC | RESILIENCY) + metric_groups=[ + MetricGroup.CONNECTION_BASIC, # Connection creation time, relaxed timeout + MetricGroup.CONNECTION_ADVANCED, # Connection wait time, timeouts, closed connections + MetricGroup.COMMAND, # Command execution duration + MetricGroup.RESILIENCY, # Error counts, maintenance notifications + MetricGroup.PUBSUB, # PubSub message counts + MetricGroup.STREAMING, # Stream message lag + MetricGroup.CSC, # Client Side Caching metrics + ], + + # Filter which commands to track + include_commands=['GET', 'SET', 'HGET'], # Only track these commands + # OR + exclude_commands=['DEBUG', 'SLOWLOG'], # Track all except these + + # Privacy controls + hide_pubsub_channel_names=True, # Hide channel names in PubSub metrics + hide_stream_names=True, # Hide stream names in streaming metrics + + # Custom histogram buckets + buckets_operation_duration=[ + 0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, + 0.25, 0.5, 1, 2.5, + ], + buckets_stream_processing_duration=[ + 0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, + 0.25, 0.5, 1, 2.5, + ], + buckets_connection_create_time=[ + 0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, + 0.25, 0.5, 1, 2.5, + ], + buckets_connection_wait_time=[ + 0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, + 0.25, 0.5, 1, 2.5, + ], +)) +# STEP_END + +# STEP_START use_redis +r = redis.Redis(host='localhost', port=6379) +r.set('key', 'value') # Metrics collected automatically +r.get('key') +# STEP_END + +# STEP_START shutdown +otel.shutdown() +# STEP_END diff --git a/static/css/otel-metrics.css b/static/css/otel-metrics.css new file mode 100644 index 0000000000..94ab3113d0 --- /dev/null +++ b/static/css/otel-metrics.css @@ -0,0 +1,391 @@ +/** + * OpenTelemetry Metrics Styling + */ + +/* Main container */ +.otel-metrics-rendered { + margin: 2rem 0; +} + +/* Metric groups section */ +.otel-metric-groups { + margin-bottom: 3rem; +} + +/* Individual metric group */ +.otel-metric-group { + margin-bottom: 2.5rem; + padding-bottom: 2rem; + border-bottom: 1px solid #e0e0e0; +} + +.otel-metric-group:last-child { + border-bottom: none; +} + +.otel-metric-group h3 { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.group-description { + color: #666; + font-style: italic; + margin-bottom: 1.5rem; +} + +/* Group ID in heading */ +.group-id { + font-size: 0.85em; + font-weight: normal; + color: #666; + background: #f5f5f5; + padding: 0.2rem 0.5rem; + border-radius: 3px; +} + +/* Individual metric */ +.otel-metric { + margin: 1.5rem 0 1.5rem 1rem; + padding: 1rem; + background: #f9f9f9; + border-left: 3px solid #0066cc; + border-radius: 4px; +} + +.otel-metric h4 { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; +} + +.metric-description { + margin: 0.5rem 0 1rem 0; + color: #333; +} + +/* Metric name in code font */ +.metric-name { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 0.95em; + background: transparent; + padding: 0; +} + +/* Metric unit */ +.metric-unit { + font-size: 0.85rem; + color: #666; + margin: 0.25rem 0 0.5rem 0; +} + +/* Metric type badge */ +.metric-type-badge-link { + text-decoration: none; +} + +.metric-type-badge { + display: inline-block; + padding: 0.2rem 0.6rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + background: #0066cc; + color: white; + border-radius: 3px; + transition: background 0.2s; +} + +.metric-type-badge-link:hover .metric-type-badge { + background: #0052a3; +} + +/* Collapsible attributes section */ +.metric-attributes-details { + margin: 1rem 0; +} + +.metric-attributes-details summary { + cursor: pointer; + font-weight: 600; + padding: 0.5rem; + background: #f0f0f0; + border-radius: 4px; + user-select: none; + list-style: none; +} + +.metric-attributes-details summary::-webkit-details-marker { + display: none; +} + +.metric-attributes-details summary::before { + content: '▶ '; + display: inline-block; + transition: transform 0.2s; +} + +.metric-attributes-details[open] summary::before { + transform: rotate(90deg); +} + +.metric-attributes-details summary:hover { + background: #e8e8e8; +} + +.metric-attributes-details .attribute-references { + margin-top: 0.5rem; +} + +/* Metadata table */ +.metric-metadata { + width: auto; + margin: 1rem 0; + font-size: 0.9rem; + border-collapse: collapse; +} + +.metric-metadata th { + text-align: left; + padding: 0.3rem 1rem 0.3rem 0; + font-weight: 600; + color: #666; + width: 120px; +} + +.metric-metadata td { + padding: 0.3rem 0; + color: #333; +} + +/* Attribute references table */ +.attribute-references { + width: 100%; + margin: 1rem 0; + border-collapse: collapse; + font-size: 0.9rem; +} + +.attribute-references th { + text-align: left; + padding: 0.5rem; + background: #f0f0f0; + border-bottom: 2px solid #ddd; + font-weight: 600; +} + +.attribute-references td { + padding: 0.5rem; + border-bottom: 1px solid #eee; +} + +.attribute-references tr:hover { + background: #f9f9f9; +} + +.attribute-link { + color: #0066cc; + text-decoration: none; + font-family: monospace; +} + +.attribute-link:hover { + text-decoration: underline; +} + +/* Cardinality badges */ +.cardinality-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + border-radius: 3px; +} + +.cardinality-required { + background: #d32f2f; + color: white; +} + +.cardinality-recommended { + background: #f57c00; + color: white; +} + +.cardinality-optional { + background: #7cb342; + color: white; +} + +/* Attributes section */ +.otel-attributes-section { + margin-top: 3rem; +} + +.attribute-table { + margin-bottom: 2rem; +} + +/* Attributes list container */ +.attributes-list { + margin: 1rem 0; +} + +/* Individual attribute card */ +.attribute-card { + margin: 1.5rem 0; + padding: 1rem; + background: #f9f9f9; + border-left: 3px solid #7cb342; + border-radius: 4px; +} + +.attribute-card h4 { + margin: 0 0 0.5rem 0; +} + +.attribute-name { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 0.95em; + background: transparent; + padding: 0; +} + +.attribute-type { + font-size: 0.85rem; + color: #666; + margin: 0.25rem 0 0.5rem 0; +} + +.attribute-type code { + background: #f5f5f5; + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-size: 0.9em; +} + +.attribute-description { + margin: 0.75rem 0; + color: #333; +} + +.attribute-examples { + font-size: 0.9rem; + color: #666; + margin: 0.5rem 0; +} + +.attribute-examples code { + background: #f5f5f5; + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-size: 0.9em; +} + +.attribute-requirement-condition { + font-size: 0.85rem; + color: #666; + font-style: italic; + margin-top: 0.5rem; +} + +/* Requirement badges */ +.requirement-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + border-radius: 3px; +} + +.requirement-required { + background: #d32f2f; + color: white; +} + +.requirement-recommended { + background: #f57c00; + color: white; +} + +.requirement-optional { + background: #7cb342; + color: white; +} + +.requirement-conditionally_required { + background: #ff9800; + color: white; +} + +.requirement-condition { + display: block; + margin-top: 0.25rem; + color: #666; + font-style: italic; +} + +/* Anchor links */ +.anchor-link { + color: inherit; + text-decoration: none; +} + +.anchor-link:hover { + text-decoration: underline; +} + +/* Copy link icon */ +.copy-link-icon { + display: inline-flex; + align-items: center; + opacity: 0; + transition: opacity 0.2s; + color: #666; + text-decoration: none; + flex-shrink: 0; +} + +h3:hover .copy-link-icon, +h4:hover .copy-link-icon { + opacity: 1; +} + +.copy-link-icon:hover { + color: #0066cc; +} + +.copy-link-icon.copied { + color: #7cb342; +} + +.copy-link-icon svg { + display: block; + width: 16px; + height: 16px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .otel-metric { + margin-left: 0; + } + + .attributes-list, + .attribute-references { + font-size: 0.85rem; + } + + .attributes-list td, + .attributes-list th, + .attribute-references td, + .attribute-references th { + padding: 0.5rem; + } +} + diff --git a/static/js/otel-metrics.js b/static/js/otel-metrics.js new file mode 100644 index 0000000000..9678588315 --- /dev/null +++ b/static/js/otel-metrics.js @@ -0,0 +1,413 @@ +/** + * OpenTelemetry Metrics Render Hook + * Renders Redis client observability metrics in a human-friendly format + */ + +document.addEventListener('DOMContentLoaded', function() { + // Find all otel-metrics-source elements + const metricsElements = document.querySelectorAll('pre.otel-metrics-source'); + + metricsElements.forEach(pre => { + try { + // Parse the JSON data + const data = JSON.parse(pre.textContent); + + // Create container for rendered output + const container = document.createElement('div'); + container.className = 'otel-metrics-rendered'; + + // Render metric groups + renderMetricGroups(container, data); + + // Render attributes section + renderAttributesSection(container, data); + + // Insert rendered content after the
 and hide the 
+      pre.style.display = 'none';
+      pre.parentNode.insertBefore(container, pre.nextSibling);
+      
+    } catch (error) {
+      console.error('Error rendering OTel metrics:', error);
+    }
+  });
+});
+
+/**
+ * Render all metric groups
+ */
+function renderMetricGroups(container, data) {
+  const groupsSection = document.createElement('div');
+  groupsSection.className = 'otel-metric-groups';
+
+  // Render each group (no heading - provided by surrounding text)
+  data.metric_groups.forEach(group => {
+    const groupDiv = renderMetricGroup(group, data.namespace_default);
+    groupsSection.appendChild(groupDiv);
+  });
+
+  container.appendChild(groupsSection);
+}
+
+/**
+ * Render a single metric group
+ */
+function renderMetricGroup(group, namespaceDefault) {
+  const groupDiv = document.createElement('div');
+  groupDiv.className = 'otel-metric-group';
+  groupDiv.id = `group-${group.id}`;
+
+  // Group title with ID in parentheses
+  const title = document.createElement('h3');
+  const titleLink = document.createElement('a');
+  titleLink.href = `#group-${group.id}`;
+  titleLink.textContent = group.title;
+  titleLink.className = 'anchor-link';
+  title.appendChild(titleLink);
+
+  // Add group ID in code font
+  title.appendChild(document.createTextNode(' '));
+  const groupIdCode = document.createElement('code');
+  groupIdCode.textContent = `(${group.id})`;
+  groupIdCode.className = 'group-id';
+  title.appendChild(groupIdCode);
+
+  // Add copy link icon
+  const copyIcon = createCopyLinkIcon(`#group-${group.id}`);
+  title.appendChild(copyIcon);
+
+  groupDiv.appendChild(title);
+
+  // Group description
+  if (group.description) {
+    const desc = document.createElement('p');
+    desc.textContent = group.description;
+    desc.className = 'group-description';
+    groupDiv.appendChild(desc);
+  }
+
+  // Render metrics in this group
+  group.metrics.forEach(metric => {
+    const metricDiv = renderMetric(metric, namespaceDefault);
+    groupDiv.appendChild(metricDiv);
+  });
+
+  return groupDiv;
+}
+
+/**
+ * Render a single metric
+ */
+function renderMetric(metric, namespaceDefault) {
+  const metricDiv = document.createElement('div');
+  metricDiv.className = 'otel-metric';
+
+  // Use metric name as-is (already includes full prefix)
+  const fullName = metric.name;
+  metricDiv.id = `metric-${fullName}`;
+
+  // Metric name heading in code font
+  const nameHeading = document.createElement('h4');
+  const nameLink = document.createElement('a');
+  nameLink.href = `#metric-${fullName}`;
+  nameLink.className = 'anchor-link';
+
+  const nameCode = document.createElement('code');
+  nameCode.textContent = fullName;
+  nameCode.className = 'metric-name';
+  nameLink.appendChild(nameCode);
+
+  nameHeading.appendChild(nameLink);
+
+  // Add copy link icon
+  const copyIcon = createCopyLinkIcon(`#metric-${fullName}`);
+  nameHeading.appendChild(copyIcon);
+
+  // Add metric type badge with link to OTel docs
+  nameHeading.appendChild(document.createTextNode(' '));
+  const typeBadgeLink = document.createElement('a');
+  typeBadgeLink.href = 'https://opentelemetry.io/docs/concepts/signals/metrics/#metric-instruments';
+  typeBadgeLink.target = '_blank';
+  typeBadgeLink.rel = 'noopener noreferrer';
+  typeBadgeLink.className = 'metric-type-badge-link';
+
+  const typeBadge = document.createElement('span');
+  typeBadge.className = 'metric-type-badge';
+  typeBadge.textContent = metric.otel.instrument_kind;
+
+  typeBadgeLink.appendChild(typeBadge);
+  nameHeading.appendChild(typeBadgeLink);
+
+  metricDiv.appendChild(nameHeading);
+
+  // Unit (if present) - displayed right under the metric name
+  if (metric.unit) {
+    const unitDiv = document.createElement('div');
+    unitDiv.className = 'metric-unit';
+    unitDiv.textContent = `Unit: ${formatUnit(metric.unit)}`;
+    metricDiv.appendChild(unitDiv);
+  }
+
+  // Description
+  const desc = document.createElement('p');
+  desc.textContent = metric.description;
+  desc.className = 'metric-description';
+  metricDiv.appendChild(desc);
+
+  // Attributes in a collapsible details element
+  if (metric.attributes && metric.attributes.length > 0) {
+    const details = document.createElement('details');
+    details.className = 'metric-attributes-details';
+
+    const summary = document.createElement('summary');
+    summary.textContent = `Attributes (${metric.attributes.length})`;
+    details.appendChild(summary);
+
+    const attrsList = renderAttributeReferences(metric.attributes);
+    details.appendChild(attrsList);
+
+    metricDiv.appendChild(details);
+  }
+
+  return metricDiv;
+}
+
+/**
+ * Render attribute references with links
+ */
+function renderAttributeReferences(attributes) {
+  const table = document.createElement('table');
+  table.className = 'attribute-references';
+
+  // Table body (no header)
+  const tbody = document.createElement('tbody');
+  attributes.forEach(attr => {
+    const row = document.createElement('tr');
+
+    // Attribute name (linked)
+    const nameCell = document.createElement('td');
+    const link = document.createElement('a');
+    link.href = `#attr-${attr.ref}`;
+    link.textContent = attr.ref;
+    link.className = 'attribute-link';
+    nameCell.appendChild(link);
+    row.appendChild(nameCell);
+
+    // Cardinality
+    const cardCell = document.createElement('td');
+    const cardBadge = document.createElement('span');
+    cardBadge.className = `cardinality-badge cardinality-${attr.cardinality}`;
+    cardBadge.textContent = attr.cardinality;
+    cardCell.appendChild(cardBadge);
+    row.appendChild(cardCell);
+
+    tbody.appendChild(row);
+  });
+  table.appendChild(tbody);
+
+  return table;
+}
+
+/**
+ * Render the attributes section
+ */
+function renderAttributesSection(container, data) {
+  const attrsSection = document.createElement('div');
+  attrsSection.className = 'otel-attributes-section';
+
+  // Add heading
+  const heading = document.createElement('h2');
+  heading.textContent = 'Attribute Definitions';
+  heading.id = 'attribute-definitions';
+  attrsSection.appendChild(heading);
+
+  // Render each table
+  data.attributes_definitions.tables.forEach(table => {
+    const tableDiv = renderAttributeTable(table);
+    attrsSection.appendChild(tableDiv);
+  });
+
+  container.appendChild(attrsSection);
+}
+
+/**
+ * Render an attribute definition table
+ */
+function renderAttributeTable(table) {
+  const tableDiv = document.createElement('div');
+  tableDiv.className = 'attribute-table';
+
+  // Table title
+  const title = document.createElement('h3');
+  title.textContent = table.title;
+  tableDiv.appendChild(title);
+
+  // Render each attribute as a card-like element
+  const attributesContainer = document.createElement('div');
+  attributesContainer.className = 'attributes-list';
+
+  table.attributes.forEach(attr => {
+    const attrDiv = renderAttributeCard(attr);
+    attributesContainer.appendChild(attrDiv);
+  });
+
+  tableDiv.appendChild(attributesContainer);
+  return tableDiv;
+}
+
+/**
+ * Render a single attribute as a card
+ */
+function renderAttributeCard(attr) {
+  const attrDiv = document.createElement('div');
+  attrDiv.className = 'attribute-card';
+  attrDiv.id = `attr-${attr.attribute}`;
+
+  // Attribute name heading
+  const nameHeading = document.createElement('h4');
+  const nameCode = document.createElement('code');
+  nameCode.textContent = attr.attribute;
+  nameCode.className = 'attribute-name';
+  nameHeading.appendChild(nameCode);
+
+  // Add requirement badge
+  nameHeading.appendChild(document.createTextNode(' '));
+  const reqBadge = document.createElement('span');
+  reqBadge.className = `requirement-badge requirement-${attr.requirement_level}`;
+  reqBadge.textContent = attr.requirement_level;
+  nameHeading.appendChild(reqBadge);
+
+  attrDiv.appendChild(nameHeading);
+
+  // Type
+  const typeDiv = document.createElement('div');
+  typeDiv.className = 'attribute-type';
+  typeDiv.textContent = 'Type: ';
+  const typeCode = document.createElement('code');
+  typeCode.textContent = attr.type;
+  typeDiv.appendChild(typeCode);
+  attrDiv.appendChild(typeDiv);
+
+  // Description
+  const descDiv = document.createElement('p');
+  descDiv.className = 'attribute-description';
+  descDiv.textContent = attr.description;
+  attrDiv.appendChild(descDiv);
+
+  // Examples
+  const examplesDiv = document.createElement('div');
+  examplesDiv.className = 'attribute-examples';
+  examplesDiv.textContent = 'Examples: ';
+  const examplesCode = document.createElement('code');
+  examplesCode.textContent = attr.examples;
+  examplesDiv.appendChild(examplesCode);
+  attrDiv.appendChild(examplesDiv);
+
+  // Requirement condition (if present)
+  if (attr.requirement_condition) {
+    const conditionDiv = document.createElement('div');
+    conditionDiv.className = 'attribute-requirement-condition';
+    conditionDiv.textContent = `Condition: ${attr.requirement_condition}`;
+    attrDiv.appendChild(conditionDiv);
+  }
+
+  return attrDiv;
+}
+
+/**
+ * Helper: Format unit object
+ */
+function formatUnit(unit) {
+  if (typeof unit === 'string') {
+    return unit;
+  }
+  // Prefer semantic value over symbol
+  return unit.semantic || unit.symbol || '';
+}
+
+/**
+ * Helper: Create SVG link icon
+ */
+function createLinkSvg() {
+  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+  svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
+  svg.setAttribute('width', '16');
+  svg.setAttribute('height', '16');
+  svg.setAttribute('viewBox', '0 0 24 24');
+  svg.setAttribute('fill', 'none');
+  svg.setAttribute('stroke', 'currentColor');
+  svg.setAttribute('stroke-width', '2');
+  svg.setAttribute('stroke-linecap', 'round');
+  svg.setAttribute('stroke-linejoin', 'round');
+
+  const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+  path1.setAttribute('d', 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71');
+  svg.appendChild(path1);
+
+  const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+  path2.setAttribute('d', 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71');
+  svg.appendChild(path2);
+
+  return svg;
+}
+
+/**
+ * Helper: Create SVG checkmark icon
+ */
+function createCheckmarkSvg() {
+  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+  svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
+  svg.setAttribute('width', '16');
+  svg.setAttribute('height', '16');
+  svg.setAttribute('viewBox', '0 0 24 24');
+  svg.setAttribute('fill', 'none');
+  svg.setAttribute('stroke', 'currentColor');
+  svg.setAttribute('stroke-width', '2');
+  svg.setAttribute('stroke-linecap', 'round');
+  svg.setAttribute('stroke-linejoin', 'round');
+
+  const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
+  polyline.setAttribute('points', '20 6 9 17 4 12');
+  svg.appendChild(polyline);
+
+  return svg;
+}
+
+/**
+ * Helper: Create a copy link icon
+ */
+function createCopyLinkIcon(anchor) {
+  const icon = document.createElement('a');
+  icon.href = anchor;
+  icon.className = 'copy-link-icon';
+  icon.setAttribute('aria-label', 'Copy link to clipboard');
+
+  // Add the link SVG icon
+  const linkSvg = createLinkSvg();
+  icon.appendChild(linkSvg);
+
+  // Copy link to clipboard on click
+  icon.addEventListener('click', function(e) {
+    e.preventDefault();
+    const fullUrl = window.location.origin + window.location.pathname + anchor;
+    navigator.clipboard.writeText(fullUrl).then(() => {
+      // Visual feedback - replace link icon with checkmark
+      icon.removeChild(icon.firstChild);
+      const checkmarkSvg = createCheckmarkSvg();
+      icon.appendChild(checkmarkSvg);
+      icon.classList.add('copied');
+
+      setTimeout(() => {
+        // Restore link icon
+        icon.removeChild(icon.firstChild);
+        const linkSvg = createLinkSvg();
+        icon.appendChild(linkSvg);
+        icon.classList.remove('copied');
+      }, 2000);
+    }).catch(err => {
+      console.error('Failed to copy link:', err);
+    });
+  });
+
+  return icon;
+}
+