chalk-remote-call-python 1.9.1__tar.gz → 1.9.2__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 (68) hide show
  1. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/PKG-INFO +31 -1
  2. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/README.md +30 -0
  3. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/Cargo.lock +7 -6
  4. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-server/Cargo.toml +4 -3
  5. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-server/src/async_service.rs +5 -5
  6. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-server/src/coalesce.rs +1 -2
  7. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-server/src/metrics.rs +16 -13
  8. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-server/src/python_bridge.rs +38 -3
  9. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-server/src/self_consumer.rs +55 -93
  10. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-server/src/server.rs +1 -2
  11. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-server/src/service.rs +1 -5
  12. chalk_remote_call_python-1.9.2/chalk_remote_call/_version.py +1 -0
  13. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/cli.py +10 -10
  14. chalk_remote_call_python-1.9.2/chalk_remote_call/errors.py +102 -0
  15. chalk_remote_call_python-1.9.2/chalk_remote_call/logging.py +29 -0
  16. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/servicer.py +26 -38
  17. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call_python.egg-info/PKG-INFO +31 -1
  18. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call_python.egg-info/SOURCES.txt +2 -0
  19. chalk_remote_call_python-1.9.1/chalk_remote_call/_version.py +0 -1
  20. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/MANIFEST.in +0 -0
  21. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/Cargo.toml +0 -0
  22. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-proto/Cargo.toml +0 -0
  23. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/chalk.auth.v1.rs +0 -0
  24. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/chalk.common.v1.rs +0 -0
  25. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/chalk.runtime.v1.rs +0 -0
  26. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/chalk.runtime.v1.tonic.rs +0 -0
  27. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/chalk.utils.v1.rs +0 -0
  28. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-proto/src/gen/descriptor.bin +0 -0
  29. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-proto/src/lib.rs +0 -0
  30. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/chalk-remote-call-server/src/lib.rs +0 -0
  31. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk-remote-call-rs/rust-toolchain.toml +0 -0
  32. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/__init__.py +0 -0
  33. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/__main__.py +0 -0
  34. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/__init__.py +0 -0
  35. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/__init__.py +0 -0
  36. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/auth/__init__.py +0 -0
  37. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/auth/v1/__init__.py +0 -0
  38. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/auth/v1/permissions_pb2.py +0 -0
  39. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/auth/v1/permissions_pb2_grpc.py +0 -0
  40. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/common/__init__.py +0 -0
  41. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/common/v1/__init__.py +0 -0
  42. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/common/v1/chalk_error_pb2.py +0 -0
  43. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/common/v1/chalk_error_pb2_grpc.py +0 -0
  44. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/runtime/__init__.py +0 -0
  45. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/runtime/v1/__init__.py +0 -0
  46. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/runtime/v1/remote_python_call_pb2.py +0 -0
  47. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/runtime/v1/remote_python_call_pb2_grpc.py +0 -0
  48. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/utils/__init__.py +0 -0
  49. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/utils/v1/__init__.py +0 -0
  50. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/utils/v1/encoding_pb2.py +0 -0
  51. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/utils/v1/encoding_pb2_grpc.py +0 -0
  52. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/utils/v1/field_change_pb2.py +0 -0
  53. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/utils/v1/field_change_pb2_grpc.py +0 -0
  54. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/utils/v1/sensitive_pb2.py +0 -0
  55. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_gen/chalk/utils/v1/sensitive_pb2_grpc.py +0 -0
  56. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/_native.pyi +0 -0
  57. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/arrow_utils.py +0 -0
  58. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/handler_loader.py +0 -0
  59. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/input_transform.py +0 -0
  60. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/server.py +0 -0
  61. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call/tracing.py +0 -0
  62. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call_python.egg-info/dependency_links.txt +0 -0
  63. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call_python.egg-info/entry_points.txt +0 -0
  64. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call_python.egg-info/requires.txt +0 -0
  65. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/chalk_remote_call_python.egg-info/top_level.txt +0 -0
  66. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/pyproject.toml +0 -0
  67. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/setup.cfg +0 -0
  68. {chalk_remote_call_python-1.9.1 → chalk_remote_call_python-1.9.2}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chalk-remote-call-python
3
- Version: 1.9.1
3
+ Version: 1.9.2
4
4
  Summary: Chalk remote call Python runtime interface client
5
5
  Author: Chalk AI, Inc.
6
6
  Project-URL: Homepage, https://chalk.ai
@@ -108,6 +108,16 @@ python -m chalk_remote_call --handler my_handler.handler
108
108
  | `--on-shutdown` | | | Dotted path to a shutdown function |
109
109
  | `--log-level` | | `INFO` | `DEBUG`, `INFO`, `WARNING`, or `ERROR` |
110
110
 
111
+ The CLI writes Python logs to stderr as one JSON object per line, with separate
112
+ `timestamp`, `severity`, `logger`, and `message` fields. Exceptions include an
113
+ `exception.stacktrace` field. Calls to `serve()` from Python retain the embedding
114
+ application's logging configuration.
115
+
116
+ Missing optional StatsD endpoints or a metrics-bus topic do not emit warnings.
117
+ Failures initializing configured metrics sinks still warn. Routine customer
118
+ StatsD configuration messages are emitted at DEBUG by the shared metrics library;
119
+ set `RUST_LOG=info,chalk_metrics::metrics::config=debug` to include them.
120
+
111
121
  ### Environment variables
112
122
 
113
123
  | Variable | Description |
@@ -212,3 +222,23 @@ To check the Rust code independently:
212
222
  ```bash
213
223
  PYO3_PYTHON=python3 cargo check --manifest-path chalk-remote-call-rs/chalk-remote-call-server/Cargo.toml
214
224
  ```
225
+
226
+ ## Remote exception transport
227
+
228
+ Handler failures retain their original exception type, message, traceback,
229
+ exception chains, and exception groups. Runtime dispatch frames are excluded
230
+ from the user traceback; full diagnostics are retained in server logs.
231
+
232
+ All call modes carry a JSON payload with `chalk_remote_error: 1` and an
233
+ `exception` object containing the `ChalkException` fields `kind`, `message`,
234
+ `stacktrace`, and `internal_stacktrace`. Direct calls carry the payload as the
235
+ unescaped gRPC status message; queued calls carry it in the error message. The
236
+ local async service additionally populates `ChalkError.exception`. This permits
237
+ existing queue transports to forward structured errors without a protobuf
238
+ migration. Clients must decode the versioned payload, not parse gRPC debug text
239
+ or classify user errors by their message. Unknown versions remain opaque.
240
+
241
+ Payloads are bounded for gRPC header limits. Truncation is marked explicitly,
242
+ with complete tracebacks kept in server logs. Exception objects and locals are
243
+ never pickled or sent. Chalk SDK-generated handlers identify their dispatch
244
+ frames, and notebook source maps identify the deployed snapshot's lines.
@@ -70,6 +70,16 @@ python -m chalk_remote_call --handler my_handler.handler
70
70
  | `--on-shutdown` | | | Dotted path to a shutdown function |
71
71
  | `--log-level` | | `INFO` | `DEBUG`, `INFO`, `WARNING`, or `ERROR` |
72
72
 
73
+ The CLI writes Python logs to stderr as one JSON object per line, with separate
74
+ `timestamp`, `severity`, `logger`, and `message` fields. Exceptions include an
75
+ `exception.stacktrace` field. Calls to `serve()` from Python retain the embedding
76
+ application's logging configuration.
77
+
78
+ Missing optional StatsD endpoints or a metrics-bus topic do not emit warnings.
79
+ Failures initializing configured metrics sinks still warn. Routine customer
80
+ StatsD configuration messages are emitted at DEBUG by the shared metrics library;
81
+ set `RUST_LOG=info,chalk_metrics::metrics::config=debug` to include them.
82
+
73
83
  ### Environment variables
74
84
 
75
85
  | Variable | Description |
@@ -174,3 +184,23 @@ To check the Rust code independently:
174
184
  ```bash
175
185
  PYO3_PYTHON=python3 cargo check --manifest-path chalk-remote-call-rs/chalk-remote-call-server/Cargo.toml
176
186
  ```
187
+
188
+ ## Remote exception transport
189
+
190
+ Handler failures retain their original exception type, message, traceback,
191
+ exception chains, and exception groups. Runtime dispatch frames are excluded
192
+ from the user traceback; full diagnostics are retained in server logs.
193
+
194
+ All call modes carry a JSON payload with `chalk_remote_error: 1` and an
195
+ `exception` object containing the `ChalkException` fields `kind`, `message`,
196
+ `stacktrace`, and `internal_stacktrace`. Direct calls carry the payload as the
197
+ unescaped gRPC status message; queued calls carry it in the error message. The
198
+ local async service additionally populates `ChalkError.exception`. This permits
199
+ existing queue transports to forward structured errors without a protobuf
200
+ migration. Clients must decode the versioned payload, not parse gRPC debug text
201
+ or classify user errors by their message. Unknown versions remain opaque.
202
+
203
+ Payloads are bounded for gRPC header limits. Truncation is marked explicitly,
204
+ with complete tracebacks kept in server logs. Exception objects and locals are
205
+ never pickled or sent. Chalk SDK-generated handlers identify their dispatch
206
+ frames, and notebook source maps identify the deployed snapshot's lines.
@@ -484,7 +484,7 @@ dependencies = [
484
484
  [[package]]
485
485
  name = "chalk-core"
486
486
  version = "0.0.0"
487
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
487
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=a0f8932ec10d3b8340af3e98cf63b800d60b4926#a0f8932ec10d3b8340af3e98cf63b800d60b4926"
488
488
  dependencies = [
489
489
  "anyhow",
490
490
  "envconfig",
@@ -499,7 +499,7 @@ dependencies = [
499
499
  [[package]]
500
500
  name = "chalk-protos"
501
501
  version = "0.0.0"
502
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
502
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=a0f8932ec10d3b8340af3e98cf63b800d60b4926#a0f8932ec10d3b8340af3e98cf63b800d60b4926"
503
503
  dependencies = [
504
504
  "prost 0.11.9",
505
505
  "prost-build",
@@ -547,6 +547,7 @@ dependencies = [
547
547
  "prost 0.13.5",
548
548
  "pyo3",
549
549
  "redis",
550
+ "serde_json",
550
551
  "tokio",
551
552
  "tokio-stream",
552
553
  "tonic 0.12.3",
@@ -560,7 +561,7 @@ dependencies = [
560
561
  [[package]]
561
562
  name = "chalk_metric_definitions"
562
563
  version = "0.0.0"
563
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
564
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=a0f8932ec10d3b8340af3e98cf63b800d60b4926#a0f8932ec10d3b8340af3e98cf63b800d60b4926"
564
565
  dependencies = [
565
566
  "chalk-protos",
566
567
  "metrics-generator",
@@ -570,7 +571,7 @@ dependencies = [
570
571
  [[package]]
571
572
  name = "chalk_metrics"
572
573
  version = "0.0.0"
573
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
574
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=a0f8932ec10d3b8340af3e98cf63b800d60b4926#a0f8932ec10d3b8340af3e98cf63b800d60b4926"
574
575
  dependencies = [
575
576
  "async-trait",
576
577
  "chalk-core",
@@ -604,7 +605,7 @@ dependencies = [
604
605
  [[package]]
605
606
  name = "chalk_statsd"
606
607
  version = "0.0.0"
607
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
608
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=a0f8932ec10d3b8340af3e98cf63b800d60b4926#a0f8932ec10d3b8340af3e98cf63b800d60b4926"
608
609
  dependencies = [
609
610
  "chalk-core",
610
611
  "chrono",
@@ -1867,7 +1868,7 @@ dependencies = [
1867
1868
  [[package]]
1868
1869
  name = "metrics-generator"
1869
1870
  version = "0.1.0"
1870
- source = "git+https://github.com/chalk-ai/chalk-private.git?rev=783e9ce6b2c#783e9ce6b2c17563ab3302e5d11f8a664ed00232"
1871
+ source = "git+https://github.com/chalk-ai/chalk-private.git?rev=a0f8932ec10d3b8340af3e98cf63b800d60b4926#a0f8932ec10d3b8340af3e98cf63b800d60b4926"
1871
1872
  dependencies = [
1872
1873
  "codegen",
1873
1874
  "serde",
@@ -21,6 +21,7 @@ arrow = { version = "54", default-features = false, features = ["ipc"] }
21
21
  tracing = "0.1"
22
22
  tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
23
23
  bytes = "1"
24
+ serde_json = "1"
24
25
  uuid = { version = "1", features = ["v4"] }
25
26
  dashmap = "5"
26
27
  futures-util = "0.3"
@@ -34,8 +35,8 @@ futures-util = "0.3"
34
35
  # chalk-private commit, mirroring how chalkcompute_remote_client depends on
35
36
  # chalk_remote_call_client. chalk-queue declares `redis = "1.0.2"`; with no
36
37
  # `[patch.crates-io]` here it builds against vanilla crates.io redis (it only
37
- # uses standard APIs). Same rev across all three so cargo reuses one checkout.
38
+ # uses standard APIs). Metrics and StatsD share a revision so their client types agree.
38
39
  [target.'cfg(target_os = "linux")'.dependencies]
39
40
  chalk-queue = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "9c61f6a938d8f885f138217ac64149e29d7a691e" }
40
- chalk_metrics = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "783e9ce6b2c" }
41
- chalk_statsd = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "783e9ce6b2c" }
41
+ chalk_metrics = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "a0f8932ec10d3b8340af3e98cf63b800d60b4926" }
42
+ chalk_statsd = { git = "https://github.com/chalk-ai/chalk-private.git", rev = "a0f8932ec10d3b8340af3e98cf63b800d60b4926" }
@@ -39,7 +39,7 @@ struct CallState {
39
39
  /// and `status`/`error` can be updated without blocking emits.
40
40
  chunks: Arc<Mutex<ChunkBuffer>>,
41
41
  /// Populated only when `status == Failed`; surfaced as a `ChalkError`.
42
- error: Option<String>,
42
+ error: Option<ChalkError>,
43
43
  /// When the call reached a terminal status. Used only by the orphan sweeper
44
44
  /// to decide when an un-drained terminal entry is safe to reclaim.
45
45
  completed_at: Option<Instant>,
@@ -189,7 +189,7 @@ impl AsyncRemoteCallService for AsyncRemoteCallServiceImpl {
189
189
  }
190
190
  Err(e) => {
191
191
  s.status = RemoteCallStatus::Failed;
192
- s.error = Some(e.to_string());
192
+ s.error = Some(e.chalk_error());
193
193
  error!(
194
194
  call_id = %id,
195
195
  exception.stacktrace = e.details(),
@@ -246,10 +246,10 @@ impl AsyncRemoteCallService for AsyncRemoteCallServiceImpl {
246
246
  drop(buf);
247
247
 
248
248
  let errors = if status == RemoteCallStatus::Failed {
249
- vec![ChalkError {
250
- message: error.unwrap_or_else(|| "remote call failed".to_string()),
249
+ vec![error.unwrap_or_else(|| ChalkError {
250
+ message: "remote call failed".to_string(),
251
251
  ..Default::default()
252
- }]
252
+ })]
253
253
  } else {
254
254
  Vec::new()
255
255
  };
@@ -193,8 +193,7 @@ impl CoalescingQueue {
193
193
  chalk_metrics::metrics::tags::Mode::Sync,
194
194
  );
195
195
  }
196
- let status =
197
- Status::unknown(format!("Exception raised during handler execution: {e}"));
196
+ let status = Status::unknown(e.to_string());
198
197
  for tx in response_txs {
199
198
  let _ = tx.send(Err(status.clone())).await;
200
199
  }
@@ -1,6 +1,6 @@
1
- //! Metrics emission for the self-consuming shim, mirroring the catalog
2
- //! consumer / fnq-server: build a `chalk_metrics` publishing pipeline at startup
3
- //! and emit per-call dequeue + processing-latency metrics.
1
+ //! Function-call metrics for synchronous gRPC calls and self-consumed queue work.
2
+ //! A shared `chalk_metrics` publishing pipeline emits call counts and
3
+ //! processing latency, independently of whether Redis queue consumption is enabled.
4
4
  //!
5
5
  //! The External Function "Function Calls Dequeued" chart reads TimescaleDB
6
6
  //! `metrics1`, which is fed only by the chalk_metrics PubSub bus (statsd goes to
@@ -15,8 +15,9 @@ use chalk_metrics::metrics::well_known_metrics::WellKnownMetricName;
15
15
  use chalk_metrics::{
16
16
  make_pubsub_publisher, BatchConfig, BusPublisher, MetricsPipeline, PublishingMetricsPipeline,
17
17
  };
18
+ use chalk_statsd::datadog::DatadogError;
18
19
  use chalk_statsd::metrics::MetricsClient;
19
- use tracing::{info, warn};
20
+ use tracing::{debug, warn};
20
21
 
21
22
  // Batch tuning — chalk_function_queue's defaults (METRICS_BATCH_* ), inlined.
22
23
  const BATCH_MAX_MESSAGES: usize = 1000;
@@ -24,26 +25,27 @@ const BATCH_MAX_BYTES: usize = 10 * 1024 * 1024; // 10 MB
24
25
  const BATCH_MAX_LATENCY: Duration = Duration::from_millis(100);
25
26
 
26
27
  /// Build and start the metrics pipeline from the environment, mirroring the
27
- /// fnq-server / catalog-consumer init. Returns `None` only if the pipeline
28
- /// object can't be constructed; the self-consumer then runs without metrics.
28
+ /// fnq-server / catalog-consumer init. Returns `None` if startup fails.
29
29
  ///
30
30
  /// The bus publisher is what reaches the metrics store (and thus the dashboard);
31
- /// statsd alone only reaches Datadog. An empty `METRICS_BUS_TOPIC` therefore
32
- /// means the dashboard won't move — surfaced as a warning, not an error.
31
+ /// StatsD and the bus are optional sinks. Missing configuration is quiet;
32
+ /// failures initializing a configured sink remain warnings.
33
33
  pub async fn init_pipeline() -> Option<Arc<PublishingMetricsPipeline>> {
34
34
  let environment_id = std::env::var("CHALK_ENVIRONMENT_ID").unwrap_or_default();
35
35
 
36
36
  let statsd = Arc::new(
37
37
  MetricsClient::new_from_default_environment().unwrap_or_else(|e| {
38
- warn!(error = %e, "failed to init statsd client; statsd metrics disabled");
38
+ if !matches!(
39
+ e.downcast_ref::<DatadogError>(),
40
+ Some(DatadogError::ConnectionInfoNotFound)
41
+ ) {
42
+ warn!(error = %e, "failed to init statsd client; statsd metrics disabled");
43
+ }
39
44
  MetricsClient::new_null()
40
45
  }),
41
46
  );
42
47
 
43
48
  let publishers: Vec<Arc<dyn BusPublisher>> = build_bus_publisher().await.into_iter().collect();
44
- if publishers.is_empty() {
45
- warn!("no metrics bus publisher configured; function call metrics will not reach the dashboard");
46
- }
47
49
 
48
50
  let metrics_pipeline: Arc<PublishingMetricsPipeline> =
49
51
  Arc::new(PublishingMetricsPipeline::new_with_env_var_defaults(
@@ -54,8 +56,9 @@ pub async fn init_pipeline() -> Option<Arc<PublishingMetricsPipeline>> {
54
56
  ));
55
57
  if let Err(e) = metrics_pipeline.start().await {
56
58
  warn!(error = %e, "failed to start metrics pipeline; metrics disabled");
59
+ return None;
57
60
  }
58
- info!("fnq self-consumer metrics pipeline started");
61
+ debug!("function-call metrics pipeline started");
59
62
  Some(metrics_pipeline)
60
63
  }
61
64
 
@@ -1,6 +1,7 @@
1
1
  use std::collections::HashMap;
2
2
  use std::sync::{Arc, Mutex};
3
3
 
4
+ use chalk_remote_call_proto::chalk::common::v1::{ChalkError, ChalkException};
4
5
  use chalk_remote_call_proto::chalk::runtime::v1::CallFunctionResponse;
5
6
  use pyo3::prelude::*;
6
7
  use pyo3::types::{PyBytes, PyDict, PyList};
@@ -321,28 +322,62 @@ fn invoke_process_fn(
321
322
  #[derive(Debug)]
322
323
  pub struct PythonError {
323
324
  details: String,
325
+ wire_message: String,
324
326
  }
325
327
 
326
328
  impl PythonError {
327
329
  fn new(message: String) -> Self {
328
- Self { details: message }
330
+ Self {
331
+ details: message.clone(),
332
+ wire_message: message,
333
+ }
329
334
  }
330
335
 
331
336
  pub fn details(&self) -> &str {
332
337
  &self.details
333
338
  }
339
+
340
+ pub fn chalk_error(&self) -> ChalkError {
341
+ let exception = serde_json::from_str::<serde_json::Value>(&self.wire_message)
342
+ .ok()
343
+ .and_then(|payload| {
344
+ let exception = payload.get("exception")?;
345
+ Some(ChalkException {
346
+ kind: exception.get("kind")?.as_str()?.to_string(),
347
+ message: exception.get("message")?.as_str()?.to_string(),
348
+ stacktrace: exception.get("stacktrace")?.as_str()?.to_string(),
349
+ internal_stacktrace: exception
350
+ .get("internal_stacktrace")?
351
+ .as_str()?
352
+ .to_string(),
353
+ })
354
+ });
355
+ ChalkError {
356
+ message: self.wire_message.clone(),
357
+ exception,
358
+ ..Default::default()
359
+ }
360
+ }
334
361
  }
335
362
 
336
363
  impl std::fmt::Display for PythonError {
337
364
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338
- write!(f, "{}", self.details)
365
+ write!(f, "{}", self.wire_message)
339
366
  }
340
367
  }
341
368
 
342
369
  fn into_python_error(py: Python<'_>, err: PyErr) -> PythonError {
343
370
  let summary = err.to_string();
344
371
  let details = format_python_traceback(py, &err).unwrap_or_else(|_| summary.clone());
345
- PythonError { details }
372
+ let wire_message = py
373
+ .import("chalk_remote_call.errors")
374
+ .and_then(|module| module.call_method1("serialize_exception", (err.value(py),)))
375
+ .and_then(|value| value.extract::<String>())
376
+ .unwrap_or_else(|_| details.clone());
377
+ PythonError {
378
+ details,
379
+ wire_message,
380
+ }
346
381
  }
347
382
 
348
383
  fn format_python_traceback(py: Python<'_>, err: &PyErr) -> PyResult<String> {
@@ -231,8 +231,6 @@ pub async fn run(
231
231
  let conn = ConnectionManager::new(client).await?;
232
232
  let queue = FunctionQueue::new(conn.clone(), config.tenant.clone());
233
233
  let input_queue = queue.bind(&config.function_name, config.queue_protocol);
234
- let legacy_queue = (config.queue_protocol == QueueProtocol::StreamV1)
235
- .then(|| queue.bind(&config.function_name, QueueProtocol::ListV1));
236
234
  let sem = queue.semaphore(&config.function_name, config.max_concurrent, config.sem_ttl);
237
235
  let sem_key = queue.sem_key_for(&config.function_name);
238
236
  let live_calls: LiveCalls = Arc::new(DashMap::new());
@@ -242,9 +240,12 @@ pub async fn run(
242
240
  Uuid::new_v4()
243
241
  );
244
242
 
245
- // XGROUP CREATE is a startup operation. Keeping it out of every XREAD and
246
- // XAUTOCLAIM avoids two avoidable Redis round trips per dispatch window.
247
- input_queue.initialize_consumer_group().await?;
243
+ if config.queue_protocol == QueueProtocol::StreamV1 {
244
+ // XGROUP CREATE is a startup operation. Keeping it out of every XREAD
245
+ // and XAUTOCLAIM avoids two avoidable Redis round trips per dispatch
246
+ // window.
247
+ input_queue.initialize_consumer_group().await?;
248
+ }
248
249
 
249
250
  spawn_renewer(
250
251
  input_queue.clone(),
@@ -265,11 +266,12 @@ pub async fn run(
265
266
  );
266
267
 
267
268
  if config.queue_protocol == QueueProtocol::StreamV1 {
269
+ let legacy_queue = queue.bind(&config.function_name, QueueProtocol::ListV1);
268
270
  return run_stream_dispatch_loop(
269
271
  &config,
270
272
  queue,
271
273
  input_queue,
272
- legacy_queue,
274
+ Some(legacy_queue),
273
275
  sem,
274
276
  conn,
275
277
  consumer_name,
@@ -281,13 +283,42 @@ pub async fn run(
281
283
  .await;
282
284
  }
283
285
 
286
+ run_list_dispatch_loop(
287
+ &config,
288
+ queue,
289
+ input_queue,
290
+ sem,
291
+ conn,
292
+ consumer_name,
293
+ live_calls,
294
+ handler,
295
+ metrics_pipeline,
296
+ coalescing_queue,
297
+ )
298
+ .await
299
+ }
300
+
301
+ /// Consume the legacy Redis List queue one call at a time. Streams use their
302
+ /// own batched dispatch loop above so they never fall through this path.
303
+ #[allow(clippy::too_many_arguments)]
304
+ async fn run_list_dispatch_loop(
305
+ config: &SelfConsumerConfig,
306
+ queue: FunctionQueue,
307
+ input_queue: BoundFunctionQueue,
308
+ sem: chalk_queue::DistributedSemaphore,
309
+ conn: ConnectionManager,
310
+ consumer_name: String,
311
+ live_calls: LiveCalls,
312
+ handler: Arc<PythonHandler>,
313
+ metrics_pipeline: Option<Arc<PublishingMetricsPipeline>>,
314
+ coalescing_queue: Option<Arc<CoalescingQueue>>,
315
+ ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
284
316
  let mut backoff = config.poll_backoff_initial;
285
317
  loop {
286
318
  // Idle-gate: if there is no work, don't take (then immediately release)
287
319
  // a semaphore slot — that churn would race genuine acquirers on other
288
- // pods. During a Stream rollout, also observe the legacy List so a new
289
- // shim can drain work queued before its revision metadata changed.
290
- match pending_depth(&input_queue, legacy_queue.as_ref()).await {
320
+ // pods.
321
+ match input_queue.current_depth().await {
291
322
  Ok(0) => {
292
323
  tokio::time::sleep(backoff).await;
293
324
  backoff = (backoff * 2).min(config.poll_backoff_max);
@@ -318,14 +349,7 @@ pub async fn run(
318
349
  }
319
350
  };
320
351
 
321
- let delivery = match dequeue_next(
322
- &input_queue,
323
- legacy_queue.as_ref(),
324
- &consumer_name,
325
- config.sem_ttl,
326
- )
327
- .await
328
- {
352
+ let delivery = match input_queue.dequeue(&consumer_name).await {
329
353
  Ok(Some(delivery)) => delivery,
330
354
  // Raced another consumer (or our own idle-gate) to an empty queue:
331
355
  // give the slot back and retry.
@@ -344,41 +368,18 @@ pub async fn run(
344
368
 
345
369
  backoff = config.poll_backoff_initial;
346
370
 
347
- // Register the token so the pod-level renewer heartbeats this lease
348
- // while the call runs.
349
- live_calls.insert(lease.token().to_string(), delivery.receipt.clone());
350
-
351
- match &coalescing_queue {
352
- // Batching on: route through the shared coalescing queue so this
353
- // call batches with inbound gRPC calls (and other self-consumed
354
- // work) under the user's batch-size/duration.
355
- Some(cq) => {
356
- tokio::spawn(run_one_coalesced_call(
357
- queue.clone(),
358
- input_queue.clone(),
359
- cq.clone(),
360
- conn.clone(),
361
- delivery,
362
- lease,
363
- live_calls.clone(),
364
- metrics_pipeline.clone(),
365
- ));
366
- }
367
- // Batching off: stream the handler's output chunk-by-chunk.
368
- None => {
369
- tokio::spawn(run_one_call(
370
- handler.clone(),
371
- queue.clone(),
372
- input_queue.clone(),
373
- conn.clone(),
374
- delivery,
375
- lease,
376
- live_calls.clone(),
377
- config.flush_timeout,
378
- metrics_pipeline.clone(),
379
- ));
380
- }
381
- }
371
+ dispatch_delivery(
372
+ queue.clone(),
373
+ input_queue.clone(),
374
+ coalescing_queue.clone(),
375
+ handler.clone(),
376
+ conn.clone(),
377
+ delivery,
378
+ lease,
379
+ live_calls.clone(),
380
+ config.flush_timeout,
381
+ metrics_pipeline.clone(),
382
+ );
382
383
  // Loop immediately — dispatch as fast as we can acquire + pop. The
383
384
  // semaphore (not this loop) is what bounds in-flight work.
384
385
  }
@@ -543,43 +544,6 @@ fn dispatch_delivery(
543
544
  }
544
545
  }
545
546
 
546
- async fn pending_depth(
547
- queue: &BoundFunctionQueue,
548
- legacy_queue: Option<&BoundFunctionQueue>,
549
- ) -> Result<u64, redis::RedisError> {
550
- let selected = queue.current_depth().await?;
551
- let legacy = match legacy_queue {
552
- Some(queue) => queue.current_depth().await?,
553
- None => 0,
554
- };
555
- Ok(selected + legacy)
556
- }
557
-
558
- async fn dequeue_next(
559
- queue: &BoundFunctionQueue,
560
- legacy_queue: Option<&BoundFunctionQueue>,
561
- consumer_name: &str,
562
- reclaim_idle: Duration,
563
- ) -> Result<Option<DequeuedCall>, Box<dyn std::error::Error + Send + Sync>> {
564
- // Recovered work wins first. The legacy List is then drained before new
565
- // Stream traffic; producers never route new work to both transports.
566
- if let Some(reclaimed) = queue
567
- .reclaim_stale(consumer_name, reclaim_idle, 1)
568
- .await?
569
- .into_iter()
570
- .next()
571
- {
572
- return Ok(Some(reclaimed));
573
- }
574
- if let Some(legacy_queue) = legacy_queue {
575
- if let Some(legacy) = legacy_queue.dequeue(consumer_name).await? {
576
- info!(function = %queue.function_name(), "draining legacy List item during Stream migration");
577
- return Ok(Some(legacy));
578
- }
579
- }
580
- queue.dequeue(consumer_name).await
581
- }
582
-
583
547
  /// Run a single queued call to completion, write its results to the call's
584
548
  /// stream, then deregister + release its lease. Always releases the lease,
585
549
  /// even if execution or stream writes fail or panic.
@@ -937,15 +901,13 @@ async fn execute_and_write(
937
901
  info!(call_id = %work.call_id, chunks = flushed, "queued call completed");
938
902
  }
939
903
  Err(e) => {
940
- // The formatted Python traceback is available, but the concrete
941
- // exception type is not carried separately across this boundary.
942
904
  stream_writer::write_error(
943
905
  conn,
944
906
  &stream_key,
945
907
  seq,
946
908
  "RemoteFunctionError",
947
- e.details(),
948
- None,
909
+ &e.to_string(),
910
+ Some(e.details()),
949
911
  )
950
912
  .await?;
951
913
  let summary: String = e.to_string().chars().take(200).collect();
@@ -80,8 +80,7 @@ pub async fn run_server(
80
80
 
81
81
  #[cfg(target_os = "linux")]
82
82
  if let Some(cfg) = crate::self_consumer::SelfConsumerConfig::from_env() {
83
- // Build the chalk_metrics pipeline once (mirrors the fnq-server/cc
84
- // main init) so the self-consumer can emit function-call metrics.
83
+ // Share the synchronous gRPC server's function-call metrics pipeline.
85
84
  let consumer_handler = python_handler.clone();
86
85
  let consumer_pipeline = pipeline.clone();
87
86
  let consumer_coalescing = coalescing_queue.clone();
@@ -144,11 +144,7 @@ impl RemoteCallService for RemoteCallServiceImpl {
144
144
  "Python handler error: {}",
145
145
  e.details()
146
146
  );
147
- let _ = error_tx
148
- .send(Err(Status::unknown(format!(
149
- "Exception raised during handler execution: {e}"
150
- ))))
151
- .await;
147
+ let _ = error_tx.send(Err(Status::unknown(e.to_string()))).await;
152
148
  }
153
149
  }
154
150
  }
@@ -0,0 +1 @@
1
+ __version__ = "1.9.2"
@@ -7,6 +7,7 @@ import sys
7
7
 
8
8
  from chalk_remote_call.handler_loader import load_function, load_handler
9
9
  from chalk_remote_call.input_transform import parse_input_args
10
+ from chalk_remote_call.logging import JsonFormatter
10
11
  from chalk_remote_call.server import serve
11
12
 
12
13
 
@@ -99,16 +100,15 @@ def main() -> None:
99
100
  )
100
101
  args = parser.parse_args()
101
102
 
102
- logging.basicConfig(
103
- level=getattr(logging, args.log_level),
104
- format="%(asctime)s %(levelname)s %(name)s: %(message)s",
105
- )
103
+ log_handler = logging.StreamHandler(sys.stderr)
104
+ log_handler.setFormatter(JsonFormatter())
105
+ logging.basicConfig(level=getattr(logging, args.log_level), handlers=[log_handler])
106
106
 
107
107
  # Load handler
108
108
  try:
109
109
  handler, auto_startup, auto_shutdown = load_handler(args.handler)
110
- except Exception as e:
111
- print(f"Error loading handler: {e}", file=sys.stderr)
110
+ except Exception:
111
+ logging.getLogger(__name__).exception("Error loading handler")
112
112
  sys.exit(1)
113
113
 
114
114
  # Determine startup function
@@ -116,8 +116,8 @@ def main() -> None:
116
116
  if args.on_startup:
117
117
  try:
118
118
  on_startup = load_function(args.on_startup, label="Startup")
119
- except Exception as e:
120
- print(f"Error loading startup function: {e}", file=sys.stderr)
119
+ except Exception:
120
+ logging.getLogger(__name__).exception("Error loading startup function")
121
121
  sys.exit(1)
122
122
  elif auto_startup is not None:
123
123
  on_startup = auto_startup
@@ -127,8 +127,8 @@ def main() -> None:
127
127
  if args.on_shutdown:
128
128
  try:
129
129
  on_shutdown = load_function(args.on_shutdown, label="Shutdown")
130
- except Exception as e:
131
- print(f"Error loading shutdown function: {e}", file=sys.stderr)
130
+ except Exception:
131
+ logging.getLogger(__name__).exception("Error loading shutdown function")
132
132
  sys.exit(1)
133
133
  elif auto_shutdown is not None:
134
134
  on_shutdown = auto_shutdown
@@ -0,0 +1,102 @@
1
+ """Versioned, data-only remote exception transport shared by all call modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import traceback
8
+ from types import TracebackType
9
+
10
+
11
+ def _user_stack(tb: TracebackType | None) -> traceback.StackSummary:
12
+ frames = []
13
+ entered_user_code = False
14
+ while tb is not None:
15
+ frame = tb.tb_frame
16
+ module = str(frame.f_globals.get("__name__", ""))
17
+ runner_frame = not entered_user_code and module in {
18
+ "asyncio.base_events",
19
+ "asyncio.runners",
20
+ "concurrent.futures._base",
21
+ "concurrent.futures.thread",
22
+ }
23
+ if not (
24
+ runner_frame
25
+ or module == "chalk_remote_call.servicer"
26
+ or module == "chalk_remote_call.errors"
27
+ or module.startswith("chalkcompute.")
28
+ or frame.f_globals.get("__chalk_generated_handler__") is True
29
+ ):
30
+ entered_user_code = True
31
+ filename, lineno = frame.f_code.co_filename, tb.tb_lineno
32
+ source_map = frame.f_globals.get("__chalk_source_map__", {})
33
+ mapped = source_map.get(lineno) if isinstance(source_map, dict) else None
34
+ if isinstance(mapped, tuple) and len(mapped) == 3:
35
+ filename, lineno, source = mapped
36
+ else:
37
+ source = None
38
+ frames.append(traceback.FrameSummary(filename, lineno, frame.f_code.co_name, line=source))
39
+ tb = tb.tb_next
40
+ return traceback.StackSummary.from_list(frames)
41
+
42
+
43
+ def _filter_traceback(summary: traceback.TracebackException, error: BaseException) -> None:
44
+ summary.stack = _user_stack(error.__traceback__)
45
+ if summary.__cause__ is not None and error.__cause__ is not None:
46
+ _filter_traceback(summary.__cause__, error.__cause__)
47
+ if summary.__context__ is not None and error.__context__ is not None:
48
+ _filter_traceback(summary.__context__, error.__context__)
49
+ children = getattr(error, "exceptions", ())
50
+ for child_summary, child in zip(getattr(summary, "exceptions", None) or (), children, strict=False):
51
+ _filter_traceback(child_summary, child)
52
+
53
+
54
+ def serialize_exception(error: BaseException) -> str:
55
+ """Keep chains and groups, without pickling exceptions or their locals."""
56
+ summary = traceback.TracebackException.from_exception(error, capture_locals=False)
57
+ internal = "".join(summary.format())
58
+ _filter_traceback(summary, error)
59
+ payload: dict[str, object] = {
60
+ "chalk_remote_error": 1,
61
+ "exception": {
62
+ "kind": type(error).__name__[:256],
63
+ "message": _safe_message(error),
64
+ "stacktrace": "".join(summary.format()),
65
+ "internal_stacktrace": internal,
66
+ },
67
+ }
68
+ # Include a trace ID when serialization runs with an active trace context.
69
+ try:
70
+ trace = importlib.import_module("opentelemetry.trace")
71
+ context = trace.get_current_span().get_span_context()
72
+ if context.is_valid:
73
+ payload["trace_id"] = format(context.trace_id, "032x")
74
+ except ImportError:
75
+ pass
76
+ # gRPC status messages live in headers. Keep the complete diagnostic in
77
+ # server logs, and bound the wire payload below common 8 KiB header limits.
78
+ encoded = json.dumps(payload, ensure_ascii=True)
79
+ exception = payload["exception"]
80
+ assert isinstance(exception, dict)
81
+ while _status_header_size(encoded) > 6000:
82
+ field = max(exception, key=lambda key: _status_header_size(json.dumps(exception[key], ensure_ascii=True)))
83
+ text = exception[field]
84
+ keep = len(text) // 4
85
+ if keep < 20:
86
+ break
87
+ exception[field] = text[:keep] + "\n... remote traceback truncated ...\n" + text[-keep:]
88
+ encoded = json.dumps(payload, ensure_ascii=True)
89
+ return encoded
90
+
91
+
92
+ def _status_header_size(message: str) -> int:
93
+ # Tonic percent-encodes these ASCII characters in grpc-message. The JSON
94
+ # serializer already escapes controls and non-ASCII code points.
95
+ return len(message) + 2 * sum(char in ' "#%<>`?{}' for char in message)
96
+
97
+
98
+ def _safe_message(error: BaseException) -> str:
99
+ try:
100
+ return str(error)
101
+ except Exception:
102
+ return "<exception message unavailable>"
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ _RECORD_FIELDS = frozenset(logging.makeLogRecord({}).__dict__) | {"message", "asctime"}
9
+
10
+
11
+ class JsonFormatter(logging.Formatter):
12
+ """Emit one JSON object per line using fields recognized by Chalk's log collector."""
13
+
14
+ def format(self, record: logging.LogRecord) -> str:
15
+ fields: dict[str, Any] = {key: value for key, value in vars(record).items() if key not in _RECORD_FIELDS}
16
+ fields.update(
17
+ timestamp=datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
18
+ severity=record.levelname,
19
+ logger=record.name,
20
+ message=record.getMessage(),
21
+ )
22
+ # Keep tracebacks out of the message and escape their newlines so the collector sees one event.
23
+ if record.exc_info:
24
+ fields["exception.stacktrace"] = self.formatException(record.exc_info)
25
+ elif record.exc_text:
26
+ fields["exception.stacktrace"] = record.exc_text
27
+ if record.stack_info:
28
+ fields["stack_info"] = self.formatStack(record.stack_info)
29
+ return json.dumps(fields, default=str)
@@ -200,34 +200,28 @@ def process_batches(
200
200
  raise ValueError(f"Input transformation failed: {e}") from e
201
201
 
202
202
  with remote_function_invocation_span(context_metadata):
203
- try:
204
- result = handler(event, context_metadata)
205
- # If the handler is async, await the coroutine.
206
- if inspect.iscoroutine(result):
207
- result = _run_async(result)
208
- except Exception as e:
209
- raise RuntimeError(f"Exception raised during handler execution: {e}") from e
203
+ result = handler(event, context_metadata)
204
+ # If the handler is async, await the coroutine.
205
+ if inspect.iscoroutine(result):
206
+ result = _run_async(result)
210
207
 
211
- try:
212
- if inspect.isgenerator(result):
213
- # Sync generator: encode each yielded value as a separate chunk.
214
- for item in result:
215
- result_batch = _coerce_to_record_batch(item)
216
- append_or_emit(encode_record_batch(result_batch))
217
- elif inspect.isasyncgen(result):
218
- # Async generator: stream when possible, otherwise preserve
219
- # the existing list-returning bridge contract.
220
- if emit is not None:
221
- _drain_async_gen(result, emit)
222
- else:
223
- for item in _collect_async_gen(result):
224
- result_batch = _coerce_to_record_batch(item)
225
- results.append(encode_record_batch(result_batch))
226
- else:
227
- result_batch = _coerce_to_record_batch(result)
208
+ if inspect.isgenerator(result):
209
+ # Sync generator: encode each yielded value as a separate chunk.
210
+ for item in result:
211
+ result_batch = _coerce_to_record_batch(item)
228
212
  append_or_emit(encode_record_batch(result_batch))
229
- except Exception as e:
230
- raise RuntimeError(f"Failed to encode response: {e}") from e
213
+ elif inspect.isasyncgen(result):
214
+ # Async generator: stream when possible, otherwise preserve
215
+ # the existing list-returning bridge contract.
216
+ if emit is not None:
217
+ _drain_async_gen(result, emit)
218
+ else:
219
+ for item in _collect_async_gen(result):
220
+ result_batch = _coerce_to_record_batch(item)
221
+ results.append(encode_record_batch(result_batch))
222
+ else:
223
+ result_batch = _coerce_to_record_batch(result)
224
+ append_or_emit(encode_record_batch(result_batch))
231
225
 
232
226
  return results
233
227
 
@@ -300,12 +294,9 @@ def process_batches_coalesced(
300
294
  # Dispatch. Contexts are passed as a parallel list — handler can correlate
301
295
  # per-caller metadata with each event by index.
302
296
  with remote_function_invocation_span(context_metadatas, coalesced_count=len(context_metadatas)):
303
- try:
304
- result = handler(events, context_metadatas)
305
- if inspect.iscoroutine(result):
306
- result = _run_async(result)
307
- except Exception as e:
308
- raise RuntimeError(f"Exception raised during handler execution: {e}") from e
297
+ result = handler(events, context_metadatas)
298
+ if inspect.iscoroutine(result):
299
+ result = _run_async(result)
309
300
 
310
301
  if not isinstance(result, list):
311
302
  raise TypeError(f"Coalesced handler must return a list, got {type(result).__name__}")
@@ -402,12 +393,9 @@ def process_batches_coalesced_combined(
402
393
  combined = combined.rename_columns(names)
403
394
 
404
395
  with remote_function_invocation_span(context_metadatas, coalesced_count=len(context_metadatas)):
405
- try:
406
- result = handler(combined, offsets, context_metadatas)
407
- if inspect.iscoroutine(result):
408
- result = _run_async(result)
409
- except Exception as e:
410
- raise RuntimeError(f"Exception raised during handler execution: {e}") from e
396
+ result = handler(combined, offsets, context_metadatas)
397
+ if inspect.iscoroutine(result):
398
+ result = _run_async(result)
411
399
 
412
400
  if isinstance(result, pa.RecordBatch):
413
401
  result_rb = result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chalk-remote-call-python
3
- Version: 1.9.1
3
+ Version: 1.9.2
4
4
  Summary: Chalk remote call Python runtime interface client
5
5
  Author: Chalk AI, Inc.
6
6
  Project-URL: Homepage, https://chalk.ai
@@ -108,6 +108,16 @@ python -m chalk_remote_call --handler my_handler.handler
108
108
  | `--on-shutdown` | | | Dotted path to a shutdown function |
109
109
  | `--log-level` | | `INFO` | `DEBUG`, `INFO`, `WARNING`, or `ERROR` |
110
110
 
111
+ The CLI writes Python logs to stderr as one JSON object per line, with separate
112
+ `timestamp`, `severity`, `logger`, and `message` fields. Exceptions include an
113
+ `exception.stacktrace` field. Calls to `serve()` from Python retain the embedding
114
+ application's logging configuration.
115
+
116
+ Missing optional StatsD endpoints or a metrics-bus topic do not emit warnings.
117
+ Failures initializing configured metrics sinks still warn. Routine customer
118
+ StatsD configuration messages are emitted at DEBUG by the shared metrics library;
119
+ set `RUST_LOG=info,chalk_metrics::metrics::config=debug` to include them.
120
+
111
121
  ### Environment variables
112
122
 
113
123
  | Variable | Description |
@@ -212,3 +222,23 @@ To check the Rust code independently:
212
222
  ```bash
213
223
  PYO3_PYTHON=python3 cargo check --manifest-path chalk-remote-call-rs/chalk-remote-call-server/Cargo.toml
214
224
  ```
225
+
226
+ ## Remote exception transport
227
+
228
+ Handler failures retain their original exception type, message, traceback,
229
+ exception chains, and exception groups. Runtime dispatch frames are excluded
230
+ from the user traceback; full diagnostics are retained in server logs.
231
+
232
+ All call modes carry a JSON payload with `chalk_remote_error: 1` and an
233
+ `exception` object containing the `ChalkException` fields `kind`, `message`,
234
+ `stacktrace`, and `internal_stacktrace`. Direct calls carry the payload as the
235
+ unescaped gRPC status message; queued calls carry it in the error message. The
236
+ local async service additionally populates `ChalkError.exception`. This permits
237
+ existing queue transports to forward structured errors without a protobuf
238
+ migration. Clients must decode the versioned payload, not parse gRPC debug text
239
+ or classify user errors by their message. Unknown versions remain opaque.
240
+
241
+ Payloads are bounded for gRPC header limits. Truncation is marked explicitly,
242
+ with complete tracebacks kept in server logs. Exception objects and locals are
243
+ never pickled or sent. Chalk SDK-generated handlers identify their dispatch
244
+ frames, and notebook source maps identify the deployed snapshot's lines.
@@ -28,8 +28,10 @@ chalk_remote_call/_native.pyi
28
28
  chalk_remote_call/_version.py
29
29
  chalk_remote_call/arrow_utils.py
30
30
  chalk_remote_call/cli.py
31
+ chalk_remote_call/errors.py
31
32
  chalk_remote_call/handler_loader.py
32
33
  chalk_remote_call/input_transform.py
34
+ chalk_remote_call/logging.py
33
35
  chalk_remote_call/server.py
34
36
  chalk_remote_call/servicer.py
35
37
  chalk_remote_call/tracing.py
@@ -1 +0,0 @@
1
- __version__ = "1.9.1"