clicksend-opentelemetry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0af816c4b5ff18d37e86e352b4d0deee0a48113c66a8a6b0afcbf2a6a41b8753
4
+ data.tar.gz: 8ae1ea1e1ffb6c812e7e5ea3d922641ed45e3da6a841f7e4e02a9236a07a3185
5
+ SHA512:
6
+ metadata.gz: c53aa4d5940bef92d06089b2e2ce40c38178a88754667692af2baa541fca6e36694664dc1cb7fc8c77a432b7bd5ab0a8395f8df9170399238990f649ce8f0d06
7
+ data.tar.gz: 57faca30aa2d16c156e336c34dd54b525947bd8a0d6c24d57ac1b1a1ac9bc84e8bc35a45301efcf1b167644c624e53f0a986611dee3d4bf92e6b9a2ed468517c
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to `clicksend-opentelemetry` are documented here. It is versioned and released
4
+ separately from [`clicksend`](https://github.com/prayantr/clicksend/blob/master/CHANGELOG.md). The format follows
5
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
6
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the version is 0.x, a minor
7
+ release may rename span attributes as OpenTelemetry's semantic conventions change.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.1.0] - 2026-10-06
12
+
13
+ The first release.
14
+
15
+ ### Added
16
+
17
+ - `Clicksend::OpenTelemetry::Instrumenter`, passed to `Clicksend::Client.new(instrumenter:)`. One
18
+ span of kind CLIENT per ClickSend API call, retries included, named `clicksend <operation>`
19
+ (`clicksend <METHOD>` when there is no operation). Attributes: `http.request.method`,
20
+ `server.address`, `server.port`, `url.path` (never the query string; `record_path: false` leaves
21
+ it out, and the path out of the exception event's message), `http.response.status_code`,
22
+ `error.type`, and `clicksend.operation`, `clicksend.idempotent`, `clicksend.attempts`,
23
+ `clicksend.ambiguous`, `clicksend.response_code`.
24
+ Each retry is a `clicksend.retry` span event. A failed call sets the span status to ERROR and
25
+ adds an `exception` event whose message is built only from the class, HTTP status, ClickSend's
26
+ `response_code` and the request line, never from the exception's own message.
27
+ - `Clicksend::OpenTelemetry::FanOut`, which sends the client's events to several instrumenters
28
+ (for example `ActiveSupport::Notifications` and the OpenTelemetry instrumenter) while the
29
+ request still runs once.
30
+ - The instrumenter never changes a call's outcome: its own failures go to
31
+ `OpenTelemetry.handle_error`, the request runs exactly once, and errors, including ambiguous
32
+ ones, pass through unchanged.
33
+ - Requires Ruby 3.3 or newer, `clicksend` 1.x (from 1.1) and `opentelemetry-api` 1.x. The SDK and
34
+ exporters are the application's choice.
35
+
36
+ [Unreleased]: https://github.com/prayantr/clicksend/compare/clicksend-opentelemetry-v0.1.0...HEAD
37
+ [0.1.0]: https://github.com/prayantr/clicksend/releases/tag/clicksend-opentelemetry-v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014 Prayantr (https://prayantr.com), Amit Solanki, Braj Pratap Singh
4
+ Copyright (c) 2026 Amit Solanki and contributors
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # clicksend-opentelemetry
2
+
3
+ OpenTelemetry spans for the [clicksend](https://github.com/prayantr/clicksend) gem, built only on
4
+ its public `instrumenter:` hook. It depends on `opentelemetry-api`; your application chooses the
5
+ SDK and exporter.
6
+
7
+ **Optional.** `clicksend` doesn't depend on this gem or on OpenTelemetry, and works the same
8
+ without it. Add it only if you want ClickSend spans in your traces.
9
+
10
+ > It lives in the clicksend repository and is released separately from `clicksend`, on its own
11
+ > version line ([CHANGELOG](CHANGELOG.md)). While it is 0.x, a minor release may rename attributes
12
+ > as OpenTelemetry's semantic conventions change. Unofficial: not affiliated with ClickSend.
13
+
14
+ Requires Ruby 3.3 or newer, `clicksend` 1.x (1.1 or later) and `opentelemetry-api` 1.x.
15
+
16
+ ## Installation
17
+
18
+ ```ruby
19
+ # Gemfile
20
+ gem "clicksend-opentelemetry", "~> 0.1"
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```ruby
26
+ # config/initializers/clicksend.rb
27
+ require "clicksend/opentelemetry"
28
+
29
+ CLICKSEND = Clicksend::Client.new(instrumenter: Clicksend::OpenTelemetry::Instrumenter.new)
30
+ ```
31
+
32
+ The instrumenter takes its tracer from the global provider when it is built. Before
33
+ `OpenTelemetry::SDK.configure` runs, that is the API's proxy, which forwards to the SDK once it is
34
+ configured, so the order of your initializers doesn't matter. Pass `tracer_provider:` to use
35
+ another provider.
36
+
37
+ To keep your `ActiveSupport::Notifications` subscribers as well, combine both with `FanOut`. Put
38
+ the OpenTelemetry instrumenter **last**: its span then covers only the request, and a subscriber
39
+ that raises after the request finished (which the client ignores) is not recorded as the span's
40
+ error.
41
+
42
+ ```ruby
43
+ CLICKSEND = Clicksend::Client.new(
44
+ instrumenter: Clicksend::OpenTelemetry::FanOut.new(ActiveSupport::Notifications, Clicksend::OpenTelemetry::Instrumenter.new)
45
+ )
46
+ ```
47
+
48
+ Options:
49
+ - `base_url:` the client's `base_url`, if it isn't the default `https://rest.clicksend.com`. The
50
+ instrumentation payload has no host, so `server.address` and `server.port` come from here.
51
+ - `record_path: false` leaves out `url.path`, and the path in the exception event's message. Paths never contain a query string, but some contain
52
+ a message ID (`sms.receipt`, `sms.cancel`), and paths you pass to `client.request` are recorded as
53
+ you wrote them.
54
+
55
+ ## What is recorded
56
+
57
+ One span per API call, retries included: kind CLIENT, named `clicksend <operation>` (for example
58
+ `clicksend sms.deliver`), or `clicksend <METHOD>` for `client.request` without `operation:`. Paths
59
+ are never used in span names, because they can contain IDs. `sms.search_history` reads history page
60
+ by page, so it produces one `clicksend sms.history` span per page.
61
+
62
+ | Attribute | From |
63
+ |---|---|
64
+ | `http.request.method`, `url.path`, `server.address`, `server.port` | the call |
65
+ | `http.response.status_code` | the last response, if there was one |
66
+ | `error.type`, span status ERROR, an `exception` event | a failed call. The event's message is built from the class, HTTP status, ClickSend's `response_code` and the request line only, never from the exception's own message |
67
+ | `clicksend.operation`, `clicksend.idempotent`, `clicksend.attempts`, `clicksend.ambiguous`, `clicksend.response_code` | the `request.clicksend` payload |
68
+
69
+ Each retry adds a `clicksend.retry` event (`clicksend.retry.attempt`, `clicksend.retry.delay` in
70
+ seconds, `error.type`, `http.response.status_code`).
71
+
72
+ `http.request.method`, `server.*`, `url.path`, `http.response.status_code` and `error.type` are
73
+ OpenTelemetry's stable HTTP attribute names. The span describes a logical call that may span
74
+ several HTTP attempts, so it doesn't claim to be an HTTP client span: it has no `url.full`, and the
75
+ attempt count is `clicksend.attempts`, not `http.request.resend_count` (which describes one
76
+ physical request).
77
+
78
+ Never recorded: phone numbers, message text, `custom_string`, request or response bodies, query
79
+ strings, headers and credentials. The specs check the exported spans for each of these.
80
+
81
+ A message ClickSend refuses inside an HTTP 200 (`Clicksend::MessageRejected`) is detected after the
82
+ HTTP call succeeded, so its span is not an error.
83
+
84
+ ## HTTP-level instrumentation records the query string
85
+
86
+ With `opentelemetry-instrumentation-faraday`, each HTTP attempt becomes a child CLIENT span of this
87
+ gem's span (Net::HTTP's own span is then suppressed). With only
88
+ `opentelemetry-instrumentation-net_http`, each attempt is a `GET`/`POST` span plus a `connect`
89
+ span. **Those spans record the query string** (`url.full` or `url.query`), and
90
+ `sms.history(to:)` and `sms.search_history` put the recipient's phone number there
91
+ (`q=to:+61...`). Those instrumentations also send a `traceparent` header to ClickSend. This gem
92
+ can't change what they record. With `opentelemetry-instrumentation-net_http` 0.29.1 and
93
+ `-faraday` 0.33.0 (checked):
94
+ - `untraced_hosts: ["rest.clicksend.com"]` on the Net::HTTP instrumentation drops its spans for
95
+ ClickSend and keeps this gem's span;
96
+ - the Faraday instrumentation has no such option, so don't enable it in an application that
97
+ calls history by number. `OpenTelemetry::Common::Utilities.untraced { ... }` around a call
98
+ suppresses the Faraday span, but this gem's span too.
99
+
100
+ ## Guarantees
101
+
102
+ The instrumenter never changes a call's outcome. Its own failures go to
103
+ `OpenTelemetry.handle_error`, the request runs exactly once, and errors, including ambiguous
104
+ ones, pass through unchanged. A send that times out is still a single-attempt, ambiguous
105
+ `Clicksend::TimeoutError` with tracing on. The specs show this with a failing tracer, a failing
106
+ span, a send timeout against `Clicksend::Testing::FakeAPI`, and a real local server.
107
+
108
+ ## Development
109
+
110
+ The specs run against the `clicksend` in this repository (`path: "../.."` in the Gemfile), with
111
+ their own bundle:
112
+
113
+ ```sh
114
+ cd companions/clicksend-opentelemetry
115
+ bundle install
116
+ bundle exec rspec
117
+ ```
118
+
119
+ When the core gem's version changes, run `bundle install` here too, so `Gemfile.lock` matches it.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clicksend
4
+ module OpenTelemetry
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "opentelemetry"
5
+ require "clicksend"
6
+ require_relative "opentelemetry/version"
7
+
8
+ module Clicksend
9
+ # OpenTelemetry tracing for Clicksend::Client, built only on the client's
10
+ # public instrumenter hook (see Clicksend::Instrumentation):
11
+ #
12
+ # require "clicksend/opentelemetry"
13
+ #
14
+ # CLICKSEND = Clicksend::Client.new(instrumenter: Clicksend::OpenTelemetry::Instrumenter.new)
15
+ #
16
+ # Each logical API call (retries included) becomes one span of kind CLIENT,
17
+ # named after its operation ("clicksend sms.deliver"); each retry is a
18
+ # "clicksend.retry" event on it. Spans never hold phone numbers, message
19
+ # text, bodies, query strings, headers or credentials: only what the
20
+ # request.clicksend payload holds, plus the server address.
21
+ module OpenTelemetry
22
+ # Spans for request.clicksend, span events for retry.clicksend.
23
+ #
24
+ # The instrumenter never changes a call's outcome. Its own failures are
25
+ # sent to ::OpenTelemetry.handle_error, the request block runs exactly once
26
+ # whatever happens, and the request's own result or exception is passed
27
+ # through untouched. (Clicksend::Connection also ignores instrumenter
28
+ # failures after the request has run; this class does not rely on that.)
29
+ class Instrumenter
30
+ SPAN_KEY = ::OpenTelemetry::Context.create_key("clicksend-span")
31
+ private_constant :SPAN_KEY
32
+
33
+ # @param tracer_provider [#tracer] defaults to the global provider (a
34
+ # proxy until the SDK is configured, so creating the client first is fine)
35
+ # @param base_url [String] the client's base_url, for server.address and
36
+ # server.port (the instrumentation payload does not carry the host)
37
+ # @param record_path [Boolean] whether to set url.path. Paths never hold
38
+ # query strings; some hold a message ID, and paths you pass to
39
+ # Client#request are reported as written.
40
+ def initialize(tracer_provider: ::OpenTelemetry.tracer_provider, base_url: Clicksend::Client::DEFAULT_BASE_URL, record_path: true)
41
+ @tracer = tracer_provider.tracer("clicksend-opentelemetry", VERSION)
42
+ uri = URI.parse(base_url)
43
+ @server = {"server.address" => uri.host, "server.port" => uri.port}.freeze
44
+ @record_path = record_path
45
+ freeze
46
+ end
47
+
48
+ # The Clicksend::Instrumentation interface.
49
+ def instrument(name, payload = {}, &block)
50
+ case name
51
+ when "request.clicksend" then block ? trace(payload, &block) : nil
52
+ when "retry.clicksend" then add_retry_event(payload, &block)
53
+ else block&.call(payload)
54
+ end
55
+ end
56
+
57
+ def inspect
58
+ "#<#{self.class.name}>"
59
+ end
60
+
61
+ private
62
+
63
+ def trace(payload)
64
+ span = safely { @tracer.start_span(span_name(payload), kind: :client, attributes: start_attributes(payload)) }
65
+ token = safely { attach(span) } if span
66
+ begin
67
+ result = yield payload
68
+ rescue Exception => e # rubocop:disable Lint/RescueException
69
+ safely { record_failure(span, e) } if span
70
+ raise
71
+ ensure
72
+ safely { finish(span, token, payload) } if span
73
+ end
74
+ result
75
+ end
76
+
77
+ # Makes the span current, so HTTP-level spans (Faraday, Net::HTTP
78
+ # instrumentation) nest under it and retry events can find it.
79
+ def attach(span)
80
+ ::OpenTelemetry::Context.attach(::OpenTelemetry::Trace.context_with_span(span).set_value(SPAN_KEY, span))
81
+ end
82
+
83
+ def finish(span, token, payload)
84
+ ::OpenTelemetry::Context.detach(token) if token
85
+ attributes = {
86
+ "http.response.status_code" => payload[:http_status],
87
+ "clicksend.response_code" => payload[:response_code],
88
+ "clicksend.attempts" => payload[:attempts],
89
+ "clicksend.ambiguous" => payload[:ambiguous]
90
+ }.compact
91
+ span.add_attributes(attributes) unless attributes.empty?
92
+ ensure
93
+ span.finish
94
+ end
95
+
96
+ # The exception's message is never recorded: an API error's message holds
97
+ # ClickSend's response_msg, and a foreign exception's may hold anything.
98
+ def record_failure(span, error)
99
+ type = error.class.name || "Exception"
100
+ span.set_attribute("error.type", type)
101
+ span.add_event("exception", attributes: {
102
+ "exception.type" => type,
103
+ "exception.message" => safe_message(error),
104
+ "exception.stacktrace" => Array(error.backtrace).join("\n")
105
+ })
106
+ span.status = ::OpenTelemetry::Trace::Status.error(type)
107
+ end
108
+
109
+ def add_retry_event(payload)
110
+ safely do
111
+ span = ::OpenTelemetry::Context.current.value(SPAN_KEY)
112
+ span&.add_event("clicksend.retry", attributes: {
113
+ "clicksend.retry.attempt" => payload[:attempt],
114
+ "clicksend.retry.delay" => payload[:delay]&.to_f,
115
+ "error.type" => payload[:error_class],
116
+ "http.response.status_code" => payload[:http_status]
117
+ }.compact)
118
+ end
119
+ yield payload if block_given?
120
+ end
121
+
122
+ def span_name(payload)
123
+ payload[:operation] ? "clicksend #{payload[:operation]}" : "clicksend #{payload[:http_method].to_s.upcase}"
124
+ end
125
+
126
+ def start_attributes(payload)
127
+ attributes = @server.merge(
128
+ "http.request.method" => payload[:http_method].to_s.upcase,
129
+ "clicksend.operation" => payload[:operation],
130
+ "clicksend.idempotent" => payload[:idempotent]
131
+ )
132
+ attributes["url.path"] = payload[:path] if @record_path
133
+ attributes.compact
134
+ end
135
+
136
+ # Class, HTTP status, ClickSend's response_code and the request line:
137
+ # all already present in the request.clicksend payload. Without
138
+ # record_path the request line is the method alone.
139
+ def safe_message(error)
140
+ parts = [error.class.name]
141
+ parts << "HTTP #{error.http_status}" if error.respond_to?(:http_status) && error.http_status
142
+ parts << error.response_code if error.respond_to?(:response_code) && error.response_code.is_a?(String)
143
+ if error.is_a?(Clicksend::Error) && error.request
144
+ parts << (@record_path ? "(#{error.request})" : "(#{error.request.http_method.to_s.upcase})")
145
+ end
146
+ parts.join(" ")
147
+ end
148
+
149
+ def safely
150
+ yield
151
+ rescue => e
152
+ ::OpenTelemetry.handle_error(exception: e, message: "clicksend-opentelemetry")
153
+ nil
154
+ end
155
+ end
156
+
157
+ # Sends every event to several instrumenters by nesting them, so the
158
+ # request still runs exactly once:
159
+ #
160
+ # Clicksend::OpenTelemetry::FanOut.new(ActiveSupport::Notifications, Clicksend::OpenTelemetry::Instrumenter.new)
161
+ #
162
+ # The first instrumenter is the outermost. Put the OpenTelemetry one last:
163
+ # its span then covers only the request, and an exception raised by an
164
+ # outer subscriber after the request finished (which the client ignores)
165
+ # is never recorded as the span's error.
166
+ class FanOut
167
+ def initialize(*instrumenters)
168
+ raise ArgumentError, "every instrumenter must respond to #instrument" unless instrumenters.all? { |i| i.respond_to?(:instrument) }
169
+
170
+ @instrumenters = instrumenters.freeze
171
+ freeze
172
+ end
173
+
174
+ def instrument(name, payload = {}, &block)
175
+ chain = @instrumenters.reverse.reduce(block) do |inner, instrumenter|
176
+ proc { |yielded| instrumenter.instrument(name, yielded || payload, &inner) }
177
+ end
178
+ chain&.call(payload)
179
+ end
180
+
181
+ def inspect
182
+ "#<#{self.class.name} #{@instrumenters.map(&:inspect).join(", ")}>"
183
+ end
184
+ end
185
+ end
186
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: clicksend-opentelemetry
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Amit Solanki
8
+ - Braj Pratap Singh
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: clicksend
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: opentelemetry-api
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.1'
41
+ description: An instrumenter for Clicksend::Client that opens one CLIENT span per
42
+ ClickSend API call, with retries as span events. Never records phone numbers, message
43
+ text, bodies, query strings or credentials. Not affiliated with ClickSend.
44
+ email:
45
+ - amit@prayantr.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - lib/clicksend/opentelemetry.rb
54
+ - lib/clicksend/opentelemetry/version.rb
55
+ homepage: https://github.com/prayantr/clicksend
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ source_code_uri: https://github.com/prayantr/clicksend/tree/master/companions/clicksend-opentelemetry
60
+ changelog_uri: https://github.com/prayantr/clicksend/blob/master/companions/clicksend-opentelemetry/CHANGELOG.md
61
+ bug_tracker_uri: https://github.com/prayantr/clicksend/issues
62
+ documentation_uri: https://github.com/prayantr/clicksend/blob/master/companions/clicksend-opentelemetry/README.md
63
+ allowed_push_host: https://rubygems.org
64
+ rubygems_mfa_required: 'true'
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.3'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 4.0.20
80
+ specification_version: 4
81
+ summary: OpenTelemetry spans for the clicksend gem's instrumentation hook.
82
+ test_files: []