src-py-lib 0.1.8__tar.gz → 0.2.0__tar.gz

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.
Files changed (37) hide show
  1. src_py_lib-0.2.0/.github/CODEOWNERS +1 -0
  2. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/.github/workflows/release.yml +34 -9
  3. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/.github/workflows/validate.yml +2 -2
  4. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/PKG-INFO +8 -4
  5. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/pyproject.toml +14 -5
  6. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/renovate.json +9 -0
  7. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/__init__.py +41 -19
  8. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/clients/graphql.py +2 -2
  9. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/clients/sourcegraph.py +35 -29
  10. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/utils/http.py +34 -2
  11. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/utils/logging.py +108 -216
  12. src_py_lib-0.2.0/src/src_py_lib/utils/telemetry.py +494 -0
  13. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/tests/test_import.py +6 -1
  14. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/tests/test_logging_http_clients.py +27 -27
  15. src_py_lib-0.2.0/uv.lock +538 -0
  16. src_py_lib-0.1.8/uv.lock +0 -302
  17. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/.github/workflows/ci.yml +0 -0
  18. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/.gitignore +0 -0
  19. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/.markdownlint-cli2.yaml +0 -0
  20. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/.python-version +0 -0
  21. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/AGENTS.md +0 -0
  22. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/LICENSE +0 -0
  23. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/README.md +0 -0
  24. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/SECURITY.md +0 -0
  25. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/clients/__init__.py +0 -0
  26. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/clients/github.py +0 -0
  27. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/clients/google_sheets.py +0 -0
  28. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/clients/linear.py +0 -0
  29. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/clients/one_password.py +0 -0
  30. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/clients/slack.py +0 -0
  31. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/py.typed +0 -0
  32. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/utils/__init__.py +0 -0
  33. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/utils/config.py +0 -0
  34. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/utils/json_cache.py +0 -0
  35. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/utils/json_types.py +0 -0
  36. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/src/src_py_lib/utils/tsv.py +0 -0
  37. {src_py_lib-0.1.8 → src_py_lib-0.2.0}/tests/test_tsv.py +0 -0
@@ -0,0 +1 @@
1
+ * @marcleblanc2
@@ -106,15 +106,34 @@ jobs:
106
106
  tag_revision="$(git rev-list -n 1 "${release_tag}")"
107
107
  git fetch --no-tags origin main
108
108
  main_revision="$(git rev-parse origin/main)"
109
- if [[ "${tag_revision}" != "${main_revision}" ]]; then
110
- echo "::error title=Tag is not origin/main::Tag '${release_tag}' points at ${tag_revision}, but origin/main is ${main_revision}."
111
- echo "::error::Tag the remote head of main, then rerun the release."
109
+ if ! git merge-base --is-ancestor "${tag_revision}" "${main_revision}"; then
110
+ echo "::error title=Tag is not on main::Tag '${release_tag}' points at ${tag_revision}, which is not reachable from origin/main."
111
+ echo "::error::Tag a commit from main, then rerun the release."
112
112
  exit 1
113
113
  fi
114
114
 
115
115
  echo "tag=${release_tag}" >> "${GITHUB_OUTPUT}"
116
116
  echo "version=${release_tag#v}" >> "${GITHUB_OUTPUT}"
117
117
 
118
+ - name: Validate runtime dependencies
119
+ run: |
120
+ runtime_requirements_file="build/release/runtime-requirements.txt"
121
+ mkdir -p "$(dirname "${runtime_requirements_file}")"
122
+ uv export \
123
+ --no-dev \
124
+ --no-emit-project \
125
+ --no-hashes \
126
+ --no-header \
127
+ --no-annotate \
128
+ --frozen \
129
+ --output-file "${runtime_requirements_file}"
130
+
131
+ if grep -Eq '(^-e[[:space:]]|^(\.\.?/)|(^|[[:space:]])file:| @ (\.\.?/|file:))' "${runtime_requirements_file}"; then
132
+ echo "::error title=Unexpected local dependency::Runtime requirements must resolve from PyPI."
133
+ cat "${runtime_requirements_file}"
134
+ exit 1
135
+ fi
136
+
118
137
  - name: Build distributions
119
138
  id: build
120
139
  run: |
@@ -174,11 +193,16 @@ jobs:
174
193
  } >> "${GITHUB_OUTPUT}"
175
194
 
176
195
  - name: Smoke test installed wheel
196
+ env:
197
+ WHEEL_PATH: ${{ steps.build.outputs.wheel_path }}
177
198
  run: |
178
- python -m venv build/release/install-venv
179
- . build/release/install-venv/bin/activate
180
- python -m pip install "${{ steps.build.outputs.wheel_path }}"
181
- python - <<'PY'
199
+ validation_dir="$(mktemp -d)"
200
+ python -m venv "${validation_dir}/venv"
201
+ . "${validation_dir}/venv/bin/activate"
202
+ python -m pip install "${GITHUB_WORKSPACE}/${WHEEL_PATH}"
203
+ (
204
+ cd "${validation_dir}"
205
+ python - <<'PY'
182
206
  import os
183
207
 
184
208
  import src_py_lib
@@ -186,6 +210,7 @@ jobs:
186
210
  if src_py_lib.__name__ != os.environ["IMPORT_NAME"]:
187
211
  raise SystemExit(f"unexpected import name: {src_py_lib.__name__}")
188
212
  PY
213
+ )
189
214
 
190
215
  - name: Write release notes
191
216
  id: notes
@@ -253,7 +278,7 @@ jobs:
253
278
 
254
279
  steps:
255
280
  - name: Download release assets
256
- uses: actions/download-artifact@v7
281
+ uses: actions/download-artifact@v8
257
282
  with:
258
283
  name: src-py-lib-release
259
284
  path: release-assets
@@ -301,7 +326,7 @@ jobs:
301
326
 
302
327
  steps:
303
328
  - name: Download built distribution
304
- uses: actions/download-artifact@v7
329
+ uses: actions/download-artifact@v8
305
330
  with:
306
331
  name: pypi-distributions
307
332
  path: dist
@@ -238,8 +238,8 @@ jobs:
238
238
  - name: Install uv
239
239
  run: python -m pip install "uv==${UV_VERSION}"
240
240
 
241
- - name: Build wheel
242
- run: uv build --wheel --out-dir dist --no-create-gitignore
241
+ - name: Build distributions
242
+ run: uv build --wheel --sdist --out-dir dist --no-create-gitignore
243
243
 
244
244
  - name: Smoke test installed wheel
245
245
  run: |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: src-py-lib
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: Reusable libraries for Sourcegraph projects
5
5
  Project-URL: Homepage, https://github.com/sourcegraph/src-py-lib
6
6
  Project-URL: Issues, https://github.com/sourcegraph/src-py-lib/issues
@@ -16,9 +16,13 @@ Classifier: Operating System :: OS Independent
16
16
  Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.11
19
- Requires-Dist: httpx<1,>=0.28
20
- Requires-Dist: pydantic<3,>=2
21
- Requires-Dist: python-dotenv<2,>=1.2
19
+ Requires-Dist: httpx<1,>=0.28.1
20
+ Requires-Dist: opentelemetry-api<2,>=1.38.0
21
+ Requires-Dist: pydantic<3,>=2.13.4
22
+ Requires-Dist: python-dotenv<2,>=1.2.2
23
+ Provides-Extra: otel
24
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.38.0; extra == 'otel'
25
+ Requires-Dist: opentelemetry-sdk<2,>=1.38.0; extra == 'otel'
22
26
  Description-Content-Type: text/markdown
23
27
 
24
28
  # src-py-lib
@@ -4,8 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [dependency-groups]
6
6
  dev = [
7
- "pyright>=1.1.409",
8
- "ruff>=0.7.0",
7
+ "opentelemetry-exporter-otlp-proto-http>=1.38.0,<2",
8
+ "opentelemetry-sdk>=1.38.0,<2",
9
+ "pyright>=1.1.410",
10
+ "ruff>=0.15.16",
9
11
  ]
10
12
 
11
13
  [project]
@@ -29,9 +31,10 @@ classifiers = [
29
31
  "Typing :: Typed",
30
32
  ]
31
33
  dependencies = [
32
- "httpx>=0.28,<1",
33
- "pydantic>=2,<3",
34
- "python-dotenv>=1.2,<2",
34
+ "httpx>=0.28.1,<1",
35
+ "opentelemetry-api>=1.38.0,<2",
36
+ "pydantic>=2.13.4,<3",
37
+ "python-dotenv>=1.2.2,<2",
35
38
  ]
36
39
  keywords = [
37
40
  "Sourcegraph"
@@ -41,6 +44,12 @@ keywords = [
41
44
  Homepage = "https://github.com/sourcegraph/src-py-lib"
42
45
  Issues = "https://github.com/sourcegraph/src-py-lib/issues"
43
46
 
47
+ [project.optional-dependencies]
48
+ otel = [
49
+ "opentelemetry-exporter-otlp-proto-http>=1.38.0,<2",
50
+ "opentelemetry-sdk>=1.38.0,<2",
51
+ ]
52
+
44
53
  [tool.hatch.build.targets.wheel]
45
54
  packages = ["src/src_py_lib"]
46
55
 
@@ -9,6 +9,15 @@
9
9
  "python"
10
10
  ],
11
11
  "allowedVersions": "<3.12"
12
+ },
13
+ {
14
+ "matchPackageNames": [
15
+ "python"
16
+ ],
17
+ "matchUpdateTypes": [
18
+ "patch"
19
+ ],
20
+ "enabled": false
12
21
  }
13
22
  ],
14
23
  "schedule": [
@@ -73,29 +73,32 @@ from src_py_lib.utils.json_types import (
73
73
  from src_py_lib.utils.logging import (
74
74
  LoggingConfig,
75
75
  LoggingSettings,
76
- TraceContext,
77
76
  configure_logging,
78
77
  critical,
79
- current_trace_context,
80
78
  debug,
81
79
  error,
82
- event,
83
80
  info,
84
- log,
85
81
  log_context,
82
+ log_event,
86
83
  logging_context,
87
84
  logging_settings_from_config,
88
- new_trace_context,
89
85
  resolve_log_level_name,
90
- sampled_traceparent,
86
+ span,
91
87
  stage,
92
88
  startup_event,
93
89
  submit_with_log_context,
94
- trace_context,
95
- trace_context_from_traceparent,
96
- traceparent_header,
97
90
  warning,
98
91
  )
92
+ from src_py_lib.utils.telemetry import (
93
+ OpenTelemetryConfig,
94
+ OpenTelemetryRuntime,
95
+ OpenTelemetrySettings,
96
+ OpenTelemetrySetupError,
97
+ configure_open_telemetry,
98
+ current_traceparent_header,
99
+ open_telemetry_settings_from_config,
100
+ traceparent_fields,
101
+ )
99
102
  from src_py_lib.utils.tsv import write_tsv
100
103
 
101
104
 
@@ -105,15 +108,33 @@ def logging(
105
108
  command: str | None = None,
106
109
  git_cwd: Path | str | None = None,
107
110
  logging_config: LoggingSettings | None = None,
111
+ open_telemetry: OpenTelemetrySettings | None = None,
108
112
  run_fields: Mapping[str, Any] | None = None,
109
113
  run_summary: Callable[[], Mapping[str, Any]] | None = None,
110
114
  ) -> AbstractContextManager[Path | None]:
111
115
  """Configure standard CLI logging and emit startup metadata."""
116
+ resolved_logging_config = logging_config
117
+ if open_telemetry is not None:
118
+ resolved_logging_config = logging_config or logging_settings_from_config(config)
119
+ resolved_logging_config = LoggingSettings(
120
+ logger_name=resolved_logging_config.logger_name,
121
+ terminal_level=resolved_logging_config.terminal_level,
122
+ log_file_level=resolved_logging_config.log_file_level,
123
+ log_file=resolved_logging_config.log_file,
124
+ logs_dir=resolved_logging_config.logs_dir,
125
+ run=resolved_logging_config.run,
126
+ retain_log_files=resolved_logging_config.retain_log_files,
127
+ suppress_http_dependency_logs=resolved_logging_config.suppress_http_dependency_logs,
128
+ resource_sample_interval_seconds=(
129
+ resolved_logging_config.resource_sample_interval_seconds
130
+ ),
131
+ open_telemetry=open_telemetry,
132
+ )
112
133
  return logging_context(
113
134
  command or _script_name(),
114
135
  config,
115
136
  git_cwd=git_cwd,
116
- logging_config=logging_config,
137
+ logging_config=resolved_logging_config,
117
138
  run_fields=run_fields,
118
139
  run_summary=run_summary,
119
140
  )
@@ -139,6 +160,10 @@ __all__ = [
139
160
  "LinearClientConfig",
140
161
  "LoggingConfig",
141
162
  "LoggingSettings",
163
+ "OpenTelemetryConfig",
164
+ "OpenTelemetryRuntime",
165
+ "OpenTelemetrySettings",
166
+ "OpenTelemetrySetupError",
142
167
  "PullRequest",
143
168
  "SlackClient",
144
169
  "SlackClientConfig",
@@ -149,15 +174,15 @@ __all__ = [
149
174
  "SourcegraphJaegerTraceError",
150
175
  "SourcegraphJaegerTraceSummary",
151
176
  "SourcegraphTrace",
152
- "TraceContext",
153
177
  "aliased_batched_query",
154
178
  "config_field",
155
179
  "config_field_names",
156
180
  "config_help_formatter",
157
181
  "config_snapshot",
182
+ "configure_open_telemetry",
158
183
  "configure_logging",
159
184
  "critical",
160
- "current_trace_context",
185
+ "current_traceparent_header",
161
186
  "debug",
162
187
  "decode_external_service_id",
163
188
  "decode_repository_id",
@@ -165,7 +190,7 @@ __all__ = [
165
190
  "encode_repository_id",
166
191
  "encode_sourcegraph_node_id",
167
192
  "error",
168
- "event",
193
+ "span",
169
194
  "gh_cli_token",
170
195
  "gcloud_adc_access_token",
171
196
  "info",
@@ -182,25 +207,22 @@ __all__ = [
182
207
  "logging",
183
208
  "logging_context",
184
209
  "logging_settings_from_config",
185
- "log",
210
+ "log_event",
186
211
  "log_context",
187
- "new_trace_context",
188
212
  "normalize_sourcegraph_endpoint",
213
+ "open_telemetry_settings_from_config",
189
214
  "parse_args",
190
215
  "pr_ref_from_url",
191
216
  "quota_project_from_adc",
192
217
  "resolve_log_level_name",
193
218
  "save_json_cache",
194
- "sampled_traceparent",
195
219
  "slack_client_from_config",
196
220
  "sourcegraph_client_from_config",
197
221
  "stage",
198
222
  "startup_event",
199
223
  "stream_connection_nodes",
200
224
  "submit_with_log_context",
201
- "trace_context",
202
- "trace_context_from_traceparent",
203
- "traceparent_header",
225
+ "traceparent_fields",
204
226
  "warning",
205
227
  "write_tsv",
206
228
  ]
@@ -11,7 +11,7 @@ from typing import cast
11
11
 
12
12
  from src_py_lib.utils.http import HTTPClient, HTTPClientError, HTTPResponse, log_safe_url
13
13
  from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_list, json_str
14
- from src_py_lib.utils.logging import event
14
+ from src_py_lib.utils.logging import span
15
15
 
16
16
  _OPERATION_NAME_RE = re.compile(r"\b(?:query|mutation|subscription)\s+(\w+)")
17
17
  HeaderProvider = Mapping[str, str] | Callable[[], Mapping[str, str]]
@@ -241,7 +241,7 @@ class GraphQLClient:
241
241
  after_variable: str = "after",
242
242
  ) -> JSONDict:
243
243
  body = {"query": query, "variables": variables or {}}
244
- with event(
244
+ with span(
245
245
  "graphql_query",
246
246
  level="debug",
247
247
  graphql_client=self.label,
@@ -10,19 +10,18 @@ import time
10
10
  from collections.abc import Iterable, Iterator, Mapping, Sequence
11
11
  from concurrent.futures import ThreadPoolExecutor, as_completed
12
12
  from dataclasses import dataclass, field
13
- from typing import Final, cast
13
+ from typing import Final
14
14
  from urllib.parse import urlsplit
15
15
 
16
16
  from src_py_lib.clients.graphql import GraphQLClient, stream_connection_nodes
17
17
  from src_py_lib.utils.config import Config, config_field
18
18
  from src_py_lib.utils.http import HTTPClient, HTTPClientError, HTTPResponse
19
19
  from src_py_lib.utils.json_types import JSONDict, JSONValue, json_dict, json_list
20
- from src_py_lib.utils.logging import (
21
- current_trace_context,
22
- new_trace_context,
23
- submit_with_log_context,
24
- trace_context_from_traceparent,
25
- traceparent_header,
20
+ from src_py_lib.utils.logging import submit_with_log_context
21
+ from src_py_lib.utils.telemetry import (
22
+ current_traceparent_header,
23
+ set_current_span_attributes,
24
+ traceparent_fields,
26
25
  )
27
26
 
28
27
  SOURCEGRAPH_EXTERNAL_SERVICE_NODE_TYPE: Final[str] = "ExternalService"
@@ -187,16 +186,16 @@ class SourcegraphClient:
187
186
  Plain HTTP endpoints are rejected unless `allow_insecure_http=True` is set
188
187
  for local development.
189
188
 
190
- Set `trace=True` to ask Sourcegraph to retain traces for each GraphQL
191
- request. Traced requests are available through `drain_traces()` and can be
192
- fetched from the instance's Jaeger/debug endpoint with
189
+ Set `fetch_sg_traces=True` to ask Sourcegraph to retain traces for each
190
+ GraphQL request. Traced requests are available through `drain_traces()` and
191
+ can be fetched from the instance's Jaeger/debug endpoint with
193
192
  `stream_jaeger_trace_summaries()`.
194
193
  """
195
194
 
196
195
  endpoint: str
197
196
  token: str
198
197
  http: HTTPClient = field(default_factory=HTTPClient)
199
- trace: bool = False
198
+ fetch_sg_traces: bool = False
200
199
  allow_insecure_http: bool = False
201
200
  _traces: queue.Queue[SourcegraphTrace] = field(
202
201
  default_factory=lambda: queue.Queue[SourcegraphTrace](), init=False, repr=False
@@ -355,7 +354,7 @@ class SourcegraphClient:
355
354
  headers=self._graphql_headers,
356
355
  label="Sourcegraph",
357
356
  http=self.http,
358
- response_hook=self._record_trace_response if self.trace else None,
357
+ response_hook=self._record_trace_response if self.fetch_sg_traces else None,
359
358
  )
360
359
 
361
360
  def _authorization_headers(self) -> dict[str, str]:
@@ -363,11 +362,11 @@ class SourcegraphClient:
363
362
 
364
363
  def _graphql_headers(self) -> dict[str, str]:
365
364
  headers = self._authorization_headers()
366
- if self.trace:
365
+ if self.fetch_sg_traces:
367
366
  headers[REQUEST_TRACE_HEADER] = "true"
368
- headers[TRACEPARENT_HEADER] = traceparent_header(
369
- current_trace_context() or new_trace_context()
370
- )
367
+ traceparent = current_traceparent_header()
368
+ if traceparent is not None:
369
+ headers[TRACEPARENT_HEADER] = traceparent
371
370
  return headers
372
371
 
373
372
  def _record_trace_response(
@@ -375,6 +374,13 @@ class SourcegraphClient:
375
374
  ) -> None:
376
375
  trace = sourcegraph_trace_from_headers(response.headers, request_headers)
377
376
  if trace is not None:
377
+ set_current_span_attributes(
378
+ {
379
+ "sourcegraph.trace_id": trace.trace_id,
380
+ "sourcegraph.trace_url": trace.trace_url,
381
+ "sourcegraph.span_id": trace.span_id,
382
+ }
383
+ )
378
384
  self._traces.put(trace)
379
385
 
380
386
 
@@ -382,22 +388,17 @@ def sourcegraph_client_from_config(
382
388
  config: SourcegraphClientConfig,
383
389
  *,
384
390
  http: HTTPClient | None = None,
385
- trace: bool = False,
391
+ fetch_sg_traces: bool = False,
386
392
  ) -> SourcegraphClient:
387
393
  """Return a Sourcegraph API client from shared Sourcegraph Config fields."""
388
394
  return SourcegraphClient(
389
395
  endpoint=config.src_endpoint,
390
396
  token=config.src_access_token,
391
397
  http=http or HTTPClient(),
392
- trace=trace,
398
+ fetch_sg_traces=fetch_sg_traces,
393
399
  )
394
400
 
395
401
 
396
- def sampled_traceparent() -> str:
397
- """Compatibility wrapper for sampled W3C traceparent generation."""
398
- return traceparent_header(sampled=True)
399
-
400
-
401
402
  def sourcegraph_trace_from_headers(
402
403
  response_headers: Mapping[str, str], request_headers: Mapping[str, str]
403
404
  ) -> SourcegraphTrace | None:
@@ -407,13 +408,13 @@ def sourcegraph_trace_from_headers(
407
408
  return None
408
409
  span_id = header_value(response_headers, TRACE_SPAN_RESPONSE_HEADER)
409
410
  trace_url = header_value(response_headers, TRACE_URL_RESPONSE_HEADER)
410
- parent = trace_context_from_traceparent(header_value(request_headers, TRACEPARENT_HEADER))
411
+ parent = traceparent_fields(header_value(request_headers, TRACEPARENT_HEADER))
411
412
  return SourcegraphTrace(
412
413
  trace_id=trace_id.lower(),
413
414
  span_id=span_id.lower() if span_id and is_hex_identifier(span_id, 16) else span_id,
414
415
  trace_url=trace_url,
415
- parent_trace_id=parent.trace_id if parent is not None else None,
416
- parent_span_id=parent.span_id if parent is not None else None,
416
+ parent_trace_id=parent.get("trace_id"),
417
+ parent_span_id=parent.get("span_id"),
417
418
  )
418
419
 
419
420
 
@@ -472,7 +473,7 @@ def summarize_jaeger_trace(
472
473
  }
473
474
  )
474
475
 
475
- hot_operations = [
476
+ hot_operations: list[JSONDict] = [
476
477
  {
477
478
  "operation": operation,
478
479
  "count": len(durations),
@@ -481,12 +482,12 @@ def summarize_jaeger_trace(
481
482
  }
482
483
  for operation, durations in durations_by_operation.items()
483
484
  ]
484
- hot_operations.sort(key=lambda operation: float(operation["sum_ms"]), reverse=True)
485
+ hot_operations.sort(key=jaeger_summary_operation_sum_ms, reverse=True)
485
486
  return SourcegraphJaegerTraceSummary(
486
487
  trace=trace_metadata,
487
488
  jaeger_found=True,
488
489
  span_count=len(spans),
489
- hot_operations=tuple(cast(JSONDict, operation) for operation in hot_operations[:10]),
490
+ hot_operations=tuple(hot_operations[:10]),
490
491
  graphql_operations=tuple(
491
492
  {"operation": operation, "count": count}
492
493
  for operation, count in graphql_operations.most_common(10)
@@ -495,6 +496,11 @@ def summarize_jaeger_trace(
495
496
  )
496
497
 
497
498
 
499
+ def jaeger_summary_operation_sum_ms(operation: JSONDict) -> float:
500
+ """Return the total duration for sorting compact Jaeger operation summaries."""
501
+ return float_value(operation.get("sum_ms"))
502
+
503
+
498
504
  def jaeger_span_tags(span: JSONDict) -> dict[str, object]:
499
505
  """Return Jaeger span tags keyed by tag name."""
500
506
  tags: dict[str, object] = {}
@@ -14,7 +14,13 @@ from typing import Final, cast
14
14
  import httpx
15
15
 
16
16
  from src_py_lib.utils.json_types import JSONDict, json_dict
17
- from src_py_lib.utils.logging import event, record_http_attempt, record_http_retry
17
+ from src_py_lib.utils.logging import record_http_attempt, record_http_retry, span
18
+ from src_py_lib.utils.telemetry import (
19
+ inject_current_trace_context,
20
+ mark_current_span_error,
21
+ record_http_client_metrics,
22
+ record_http_client_retry,
23
+ )
18
24
 
19
25
  DEFAULT_TIMEOUT_SECONDS: Final[float] = 30.0
20
26
  DEFAULT_MAX_CONNECTIONS: Final[int] = 20
@@ -158,8 +164,9 @@ class HTTPClient:
158
164
  body = json.dumps(json_body).encode("utf-8")
159
165
  request_headers.setdefault("Content-Type", "application/json")
160
166
  for attempt in range(1, self.max_attempts + 1):
167
+ attempt_started = time.perf_counter()
161
168
  try:
162
- with event(
169
+ with span(
163
170
  "http_request",
164
171
  level="debug",
165
172
  method=method,
@@ -168,6 +175,7 @@ class HTTPClient:
168
175
  request_headers=_headers_for_log(request_headers),
169
176
  request_bytes=len(body or b""),
170
177
  ) as fields:
178
+ inject_current_trace_context(request_headers)
171
179
  response = self._client.request(
172
180
  method,
173
181
  request_url,
@@ -182,12 +190,22 @@ class HTTPClient:
182
190
  http_version = _response_http_version(response)
183
191
  if http_version is not None:
184
192
  fields["http_version"] = http_version
193
+ duration_seconds = time.perf_counter() - attempt_started
185
194
  record_http_attempt(
186
195
  request_bytes=len(body or b""),
187
196
  response_bytes=len(payload),
188
197
  status_code=response.status_code,
189
198
  )
199
+ record_http_client_metrics(
200
+ method=method,
201
+ url=request_url,
202
+ duration_seconds=duration_seconds,
203
+ request_bytes=len(body or b""),
204
+ response_bytes=len(payload),
205
+ status_code=response.status_code,
206
+ )
190
207
  if response.status_code >= 400:
208
+ mark_current_span_error(f"HTTP {response.status_code}")
191
209
  body_text = _body_preview(payload)
192
210
  if not self._should_retry(response.status_code, attempt):
193
211
  raise HTTPClientError(
@@ -198,6 +216,11 @@ class HTTPClient:
198
216
  headers=dict(response.headers),
199
217
  )
200
218
  record_http_retry()
219
+ record_http_client_retry(
220
+ method=method,
221
+ url=request_url,
222
+ status_code=response.status_code,
223
+ )
201
224
  self._sleep_before_retry(attempt, response.headers.get("Retry-After"))
202
225
  else:
203
226
  return HTTPResponse(
@@ -210,7 +233,15 @@ class HTTPClient:
210
233
  except HTTPClientError:
211
234
  raise
212
235
  except httpx.TransportError as exception:
236
+ duration_seconds = time.perf_counter() - attempt_started
213
237
  record_http_attempt(request_bytes=len(body or b""), transport_error=True)
238
+ record_http_client_metrics(
239
+ method=method,
240
+ url=request_url,
241
+ duration_seconds=duration_seconds,
242
+ request_bytes=len(body or b""),
243
+ transport_error=True,
244
+ )
214
245
  if not self._should_retry(None, attempt):
215
246
  failure = (
216
247
  "timed out" if isinstance(exception, httpx.TimeoutException) else "failed"
@@ -220,6 +251,7 @@ class HTTPClient:
220
251
  f"{_exception_message(exception)}"
221
252
  ) from exception
222
253
  record_http_retry()
254
+ record_http_client_retry(method=method, url=request_url)
223
255
  self._sleep_before_retry(attempt, None)
224
256
  raise AssertionError("HTTP retry loop exited without returning or raising")
225
257