threadify-sdk 0.2.7__tar.gz → 0.2.9__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 (28) hide show
  1. {threadify_sdk-0.2.7/threadify_sdk.egg-info → threadify_sdk-0.2.9}/PKG-INFO +25 -2
  2. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/README.md +24 -1
  3. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/pyproject.toml +1 -1
  4. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_client.py +16 -9
  5. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_connection.py +100 -0
  6. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_models.py +4 -3
  7. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_step.py +2 -3
  8. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/connection.py +45 -21
  9. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/data_retriever.py +6 -2
  10. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/models.py +1 -0
  11. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/otel_exporter.py +16 -3
  12. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/step.py +0 -12
  13. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/thread.py +1 -0
  14. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9/threadify_sdk.egg-info}/PKG-INFO +25 -2
  15. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/CHANGELOG.md +0 -0
  16. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/LICENSE +0 -0
  17. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/MANIFEST.in +0 -0
  18. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/setup.cfg +0 -0
  19. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_data_retriever.py +0 -0
  20. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_notification.py +0 -0
  21. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/tests/test_thread.py +0 -0
  22. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/__init__.py +0 -0
  23. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/client.py +0 -0
  24. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify/notification.py +0 -0
  25. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/SOURCES.txt +0 -0
  26. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/dependency_links.txt +0 -0
  27. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/requires.txt +0 -0
  28. {threadify_sdk-0.2.7 → threadify_sdk-0.2.9}/threadify_sdk.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadify-sdk
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: Python SDK for Threadify — Service-delivery intelligence. Track every customer request from start to finish across every system, team, and partner. Visit: https://threadify.dev
5
5
  Author-email: Threadify Team <team@threadify.dev>
6
6
  License: MIT
@@ -63,7 +63,14 @@ async def main():
63
63
  return
64
64
 
65
65
  try:
66
- thread = await conn.start("Order-123")
66
+ thread = await conn.start(
67
+ contract_name="order_processing",
68
+ role="customer",
69
+ refs={"order_id": "ORD-123"},
70
+ tags=["priority"],
71
+ )
72
+
73
+ await thread.add_refs({"crm_id": "CRM-456"})
67
74
 
68
75
  # Easy chaining!
69
76
  await (
@@ -130,6 +137,22 @@ thread = await conn.join(
130
137
  )
131
138
  ```
132
139
 
140
+ ### Start options
141
+
142
+ `Connection.start(...)` supports named options for contract, role, refs, and tags, while preserving the older positional forms:
143
+
144
+ ```python
145
+ thread = await conn.start(
146
+ contract_name="order_processing",
147
+ role="customer",
148
+ refs={"customer_id": "123"},
149
+ tags=["priority"],
150
+ )
151
+ thread = await conn.start("Order-123", "customer")
152
+ thread = await conn.start({"customer_id": "123"}, "customer")
153
+ thread = await conn.start("Order-123") # contract is optional
154
+ ```
155
+
133
156
  ## Subscriptions
134
157
 
135
158
  Preferred API:
@@ -36,7 +36,14 @@ async def main():
36
36
  return
37
37
 
38
38
  try:
39
- thread = await conn.start("Order-123")
39
+ thread = await conn.start(
40
+ contract_name="order_processing",
41
+ role="customer",
42
+ refs={"order_id": "ORD-123"},
43
+ tags=["priority"],
44
+ )
45
+
46
+ await thread.add_refs({"crm_id": "CRM-456"})
40
47
 
41
48
  # Easy chaining!
42
49
  await (
@@ -103,6 +110,22 @@ thread = await conn.join(
103
110
  )
104
111
  ```
105
112
 
113
+ ### Start options
114
+
115
+ `Connection.start(...)` supports named options for contract, role, refs, and tags, while preserving the older positional forms:
116
+
117
+ ```python
118
+ thread = await conn.start(
119
+ contract_name="order_processing",
120
+ role="customer",
121
+ refs={"customer_id": "123"},
122
+ tags=["priority"],
123
+ )
124
+ thread = await conn.start("Order-123", "customer")
125
+ thread = await conn.start({"customer_id": "123"}, "customer")
126
+ thread = await conn.start("Order-123") # contract is optional
127
+ ```
128
+
106
129
  ## Subscriptions
107
130
 
108
131
  Preferred API:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "threadify-sdk"
7
- version = "0.2.7"
7
+ version = "0.2.9"
8
8
  description = "Python SDK for Threadify — Service-delivery intelligence. Track every customer request from start to finish across every system, team, and partner. Visit: https://threadify.dev"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -9,14 +9,21 @@ from threadify.models import ConnectOptions
9
9
 
10
10
  class TestThreadifyConnect:
11
11
  @pytest.mark.asyncio
12
- async def test_requires_ws_url(self, monkeypatch):
13
- ws_connect = AsyncMock(
14
- side_effect=AssertionError("websockets.connect should not be called")
15
- )
12
+ async def test_connect_uses_default_ws_url_when_not_provided(self, monkeypatch):
13
+ ws = AsyncMock()
14
+ ws.send = AsyncMock()
15
+ ws.recv = AsyncMock(return_value=json.dumps({"action": "connect", "status": "success"}))
16
+ ws.close = AsyncMock()
17
+
18
+ ws_connect = AsyncMock(return_value=ws)
16
19
  monkeypatch.setattr("threadify.client.websockets.connect", ws_connect)
17
20
 
18
- with pytest.raises(ValueError, match="ws_url is required"):
19
- await Threadify.connect("test-api-key", "test-service")
21
+ conn = await Threadify.connect("test-api-key", "test-service")
22
+
23
+ ws_connect.assert_awaited_once_with("wss://eng.threadify.dev/threads")
24
+ sent_msg = json.loads(ws.send.call_args.args[0])
25
+ assert sent_msg["serviceName"] == "test-service"
26
+ assert conn.is_connected is True
20
27
 
21
28
  @pytest.mark.asyncio
22
29
  async def test_connect_with_explicit_ws_url(self, monkeypatch):
@@ -64,6 +71,6 @@ class TestThreadifyConnect:
64
71
 
65
72
 
66
73
  class TestThreadifyFactory:
67
- def test_create_requires_ws_url(self):
68
- with pytest.raises(ValueError, match="ws_url is required"):
69
- Threadify.create("test-api-key")
74
+ def test_create_uses_default_ws_url(self):
75
+ factory = Threadify.create("test-api-key")
76
+ assert factory._options.ws_url == "wss://eng.threadify.dev/threads"
@@ -36,6 +36,7 @@ def _make_connection():
36
36
  conn._notification_handlers = {}
37
37
  conn._active_subscriptions = {}
38
38
  conn._processed_notifications = set()
39
+ conn._processed_notifications_max_size = 10_000
39
40
  conn._recv_queue = asyncio.Queue()
40
41
  conn._data_retriever = None
41
42
  import logging
@@ -120,6 +121,105 @@ class TestConnectionProperties:
120
121
  assert conn.is_connected is True
121
122
 
122
123
 
124
+ class TestStart:
125
+ @pytest.mark.asyncio
126
+ async def test_start_with_label_and_optional_contract(self):
127
+ conn = _make_connection()
128
+ conn._send = AsyncMock()
129
+ conn._wait_response = AsyncMock(
130
+ return_value={
131
+ "action": "startThread",
132
+ "status": "success",
133
+ "threadId": "thread-123",
134
+ }
135
+ )
136
+
137
+ thread = await conn.start("customer-123", "customer")
138
+
139
+ assert thread.thread_id == "thread-123"
140
+ assert thread.role == "test"
141
+ assert thread.refs["label"] == "customer-123"
142
+ assert thread.refs["serviceName"] == "test-service"
143
+ sent_msg = conn._send.call_args.args[0]
144
+ assert sent_msg["refs"]["label"] == "customer-123"
145
+ assert sent_msg["contractName"] == "customer"
146
+ assert sent_msg["role"] == "test"
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_start_with_refs_and_contract(self):
150
+ conn = _make_connection()
151
+ conn._send = AsyncMock()
152
+ conn._wait_response = AsyncMock(
153
+ return_value={
154
+ "action": "startThread",
155
+ "status": "success",
156
+ "threadId": "thread-legacy-123",
157
+ }
158
+ )
159
+
160
+ thread = await conn.start(refs={"customer_id": "123"}, contract_name="customer")
161
+
162
+ assert thread.thread_id == "thread-legacy-123"
163
+ assert thread.refs["customer_id"] == "123"
164
+ assert thread.refs["serviceName"] == "test-service"
165
+ sent_msg = conn._send.call_args.args[0]
166
+ assert sent_msg["refs"]["customer_id"] == "123"
167
+ assert "label" not in sent_msg["refs"]
168
+ assert sent_msg["contractName"] == "customer"
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_start_without_contract_still_works(self):
172
+ conn = _make_connection()
173
+ conn._send = AsyncMock()
174
+ conn._wait_response = AsyncMock(
175
+ return_value={
176
+ "action": "startThread",
177
+ "status": "success",
178
+ "threadId": "thread-no-contract-123",
179
+ }
180
+ )
181
+
182
+ thread = await conn.start("customer-123")
183
+
184
+ assert thread.thread_id == "thread-no-contract-123"
185
+ assert thread.refs["label"] == "customer-123"
186
+ assert thread.refs["serviceName"] == "test-service"
187
+ sent_msg = conn._send.call_args.args[0]
188
+ assert sent_msg["refs"]["label"] == "customer-123"
189
+ assert "contractName" not in sent_msg
190
+ assert "role" not in sent_msg
191
+
192
+ @pytest.mark.asyncio
193
+ async def test_start_accepts_keyword_config_options(self):
194
+ conn = _make_connection()
195
+ conn._send = AsyncMock()
196
+ conn._wait_response = AsyncMock(
197
+ return_value={
198
+ "action": "startThread",
199
+ "status": "success",
200
+ "threadId": "thread-options-123",
201
+ }
202
+ )
203
+
204
+ thread = await conn.start(
205
+ contract_name="order_processing",
206
+ role="merchant",
207
+ refs={"customer_id": "123"},
208
+ tags=["priority", "external"],
209
+ )
210
+
211
+ assert thread.thread_id == "thread-options-123"
212
+ assert thread.role == "merchant"
213
+ assert thread.tags == ["priority", "external"]
214
+ assert thread.refs["customer_id"] == "123"
215
+ assert thread.refs["serviceName"] == "test-service"
216
+ sent_msg = conn._send.call_args.args[0]
217
+ assert sent_msg["contractName"] == "order_processing"
218
+ assert sent_msg["role"] == "merchant"
219
+ assert sent_msg["refs"]["customer_id"] == "123"
220
+ assert sent_msg["tags"] == ["priority", "external"]
221
+
222
+
123
223
  class TestJoin:
124
224
  @pytest.mark.asyncio
125
225
  async def test_join_with_token_keyword(self):
@@ -3,6 +3,7 @@ import pytest
3
3
  from threadify.models import (
4
4
  DEFAULT_CONNECT_TIMEOUT,
5
5
  DEFAULT_MAX_IN_FLIGHT,
6
+ DEFAULT_WS_URL,
6
7
  ConnectOptions,
7
8
  StepResult,
8
9
  StepStatus,
@@ -77,10 +78,10 @@ class TestConnectOptions:
77
78
  def test_defaults(self):
78
79
  opts = ConnectOptions()
79
80
  opts.with_defaults()
80
- assert opts.ws_url == ""
81
+ assert opts.ws_url == DEFAULT_WS_URL
81
82
  assert opts.max_in_flight == DEFAULT_MAX_IN_FLIGHT
82
83
  assert opts.connect_timeout == DEFAULT_CONNECT_TIMEOUT
83
- assert opts.graphql_url == ""
84
+ assert opts.graphql_url == derive_graphql_url(DEFAULT_WS_URL)
84
85
 
85
86
  def test_preserves_custom(self):
86
87
  opts = ConnectOptions(
@@ -100,7 +101,7 @@ class TestConnectOptions:
100
101
  opts2 = ConnectOptions(ws_url="wss://custom.com/threads", max_in_flight=100)
101
102
  opts2.validate() # Should not raise.
102
103
 
103
- def test_validate_requires_ws_url(self):
104
+ def test_validate_requires_non_empty_ws_url(self):
104
105
  opts = ConnectOptions(ws_url="")
105
106
  with pytest.raises(ValueError, match="ws_url is required"):
106
107
  opts.validate()
@@ -24,9 +24,7 @@ class TestThreadStepFluent:
24
24
  def test_chaining(self):
25
25
  thread = _make_thread()
26
26
  step = ThreadStep("order_placed", thread, "test-svc")
27
- result = step.add_context({"orderId": "ORD-1", "amount": "99.99"}).add_refs(
28
- {"stripe": "pi_abc"}
29
- )
27
+ result = step.add_context({"orderId": "ORD-1", "amount": "99.99"})
30
28
  assert result is step # Fluent returns self.
31
29
  assert step.context == {"orderId": "ORD-1", "amount": "99.99"}
32
30
 
@@ -278,3 +276,4 @@ class TestStepProperties:
278
276
  assert data["action"] == "recordThreadEvent"
279
277
  assert data["stepName"] == "order_placed"
280
278
  assert data["threadId"] == "thread-001"
279
+ assert "refs" not in data
@@ -20,6 +20,8 @@ from threadify.models import (
20
20
  ACTION_START_THREAD,
21
21
  ACTION_SUBSCRIBE,
22
22
  ACTION_UNSUBSCRIBE,
23
+ DEFAULT_PROCESSED_MAX_SIZE,
24
+ FIELD_ACCESS_LEVEL,
23
25
  FIELD_ACK_TOKEN,
24
26
  FIELD_ACTION,
25
27
  FIELD_CONTRACT_NAME,
@@ -35,11 +37,11 @@ from threadify.models import (
35
37
  FIELD_SERVICE_NAME,
36
38
  FIELD_STATUS,
37
39
  FIELD_STEP_NAME,
40
+ FIELD_TAGS,
38
41
  FIELD_THREAD_ID,
39
42
  FIELD_THREAD_ID_ACK,
40
43
  FIELD_THREAD_TOKEN,
41
44
  STATUS_SUCCESS,
42
- DEFAULT_PROCESSED_MAX_SIZE,
43
45
  RefQuery,
44
46
  first_non_empty,
45
47
  require_non_empty,
@@ -144,6 +146,8 @@ class Connection:
144
146
  contract_name: str = "",
145
147
  service_name: str = "",
146
148
  refs: dict[str, Any] | None = None,
149
+ tags: list[str] | None = None,
150
+ role: str = "",
147
151
  ) -> ThreadInstance:
148
152
  from threadify.thread import ThreadInstance
149
153
 
@@ -152,27 +156,36 @@ class Connection:
152
156
 
153
157
  effective_service = first_non_empty(service_name, self._service_name)
154
158
 
155
- # Prepare refs
156
159
  message_refs = (refs or {}).copy()
160
+ label_value = label
161
+
157
162
  message_refs[FIELD_SERVICE_NAME] = effective_service
158
- if label:
159
- message_refs["label"] = label
163
+ if label_value:
164
+ message_refs["label"] = label_value
160
165
 
161
166
  msg: dict[str, Any] = {
162
167
  FIELD_ACTION: ACTION_START_THREAD,
163
168
  FIELD_REFS: message_refs,
164
169
  }
165
170
 
171
+ effective_role = role
166
172
  if contract_name:
167
173
  msg[FIELD_CONTRACT_NAME] = contract_name
168
- role = ""
169
- # Check if role is in refs or if we should derive it
170
- if effective_service:
171
- role = effective_service.removesuffix("-service")
172
- else:
173
- role = "participant"
174
-
175
- msg[FIELD_ROLE] = role
174
+ if not effective_role:
175
+ if effective_service:
176
+ effective_role = effective_service.removesuffix("-service")
177
+ else:
178
+ effective_role = "participant"
179
+
180
+ if effective_role:
181
+ msg[FIELD_ROLE] = effective_role
182
+
183
+ # Validate and attach tags if provided
184
+ if tags:
185
+ for t in tags:
186
+ if not isinstance(t, str) or not t.strip():
187
+ raise ValueError("Each tag must be a non-empty string")
188
+ msg[FIELD_TAGS] = list(tags)
176
189
 
177
190
  await self._send(msg)
178
191
 
@@ -182,7 +195,15 @@ class Connection:
182
195
  raise RuntimeError(resp.get(FIELD_MESSAGE, "failed to start thread"))
183
196
 
184
197
  thread_id = resp[FIELD_THREAD_ID]
185
- thread = ThreadInstance(self, thread_id, contract_name, "", resp.get(FIELD_ACCESS_LEVEL, ""), None)
198
+ thread = ThreadInstance(
199
+ self,
200
+ thread_id,
201
+ contract_name,
202
+ effective_role,
203
+ resp.get(FIELD_ACCESS_LEVEL, ""),
204
+ message_refs.copy(),
205
+ )
206
+ thread.tags = list(tags) if tags else []
186
207
  self._threads[thread_id] = thread
187
208
  self._logger.debug(f"Thread started: {thread_id}")
188
209
  return thread
@@ -209,16 +230,19 @@ class Connection:
209
230
  msg[FIELD_THREAD_TOKEN] = token
210
231
  elif thread_id is not None:
211
232
  require_non_empty("thread_id", thread_id)
212
- require_non_empty("role", role)
213
233
  msg[FIELD_THREAD_ID] = thread_id
214
- msg[FIELD_ROLE] = role
215
- elif token_or_thread_id is not None:
216
- require_non_empty("token_or_thread_id", token_or_thread_id)
217
234
  if role:
218
- msg[FIELD_THREAD_ID] = token_or_thread_id
219
235
  msg[FIELD_ROLE] = role
220
- else:
236
+ elif token_or_thread_id is not None:
237
+ require_non_empty("token_or_thread_id", token_or_thread_id)
238
+ # If it has 3 parts (JWT) or is a long string, it's likely a token. Otherwise it's a thread ID.
239
+ is_token = len(token_or_thread_id.split('.')) == 3 or len(token_or_thread_id) > 50
240
+ if is_token and not role:
221
241
  msg[FIELD_THREAD_TOKEN] = token_or_thread_id
242
+ else:
243
+ msg[FIELD_THREAD_ID] = token_or_thread_id
244
+ if role:
245
+ msg[FIELD_ROLE] = role
222
246
  else:
223
247
  raise ValueError("provide token, thread_id+role, or token_or_thread_id")
224
248
 
@@ -264,7 +288,7 @@ class Connection:
264
288
  event: str,
265
289
  step_name_or_handler: str | NotificationHandler | None = None,
266
290
  handler: NotificationHandler | None = None,
267
- ) -> "Connection":
291
+ ) -> Connection:
268
292
  """Subscribe to notifications for a step or thread-level event.
269
293
 
270
294
  Supports two signatures:
@@ -296,7 +320,7 @@ class Connection:
296
320
  self._notification_handlers.setdefault(key, []).append(actual_handler)
297
321
  return self
298
322
 
299
- def unsubscribe(self, event: str, step_name: str = "") -> "Connection":
323
+ def unsubscribe(self, event: str, step_name: str = "") -> Connection:
300
324
  """Unsubscribe from notifications.
301
325
 
302
326
  Args:
@@ -183,8 +183,12 @@ class DataRetriever:
183
183
  if q.started_before:
184
184
  variables["startedBefore"] = q.started_before
185
185
  data = await self._client.query(query, variables)
186
- connection = data.get("threadsByRef") or {}
187
- threads_list = connection.get("threads") or []
186
+ threads_by_ref = data.get("threadsByRef")
187
+ if isinstance(threads_by_ref, list):
188
+ threads_list = threads_by_ref
189
+ else:
190
+ connection = threads_by_ref or {}
191
+ threads_list = connection.get("threads") or []
188
192
  return [ArchivedThread(t, self._client) for t in threads_list if isinstance(t, dict)]
189
193
 
190
194
  async def get_validation_results(self, thread_id: str, step_name: str = "") -> list[dict[str, Any]]:
@@ -75,6 +75,7 @@ FIELD_OWNER_ID = "ownerId"
75
75
  FIELD_SEVERITY = "severity"
76
76
  FIELD_TIMESTAMP = "timestamp"
77
77
  FIELD_VIOLATION_TYPE = "violationType"
78
+ FIELD_TAGS = "tags"
78
79
 
79
80
  # Protocol status values
80
81
  STATUS_SUCCESS = "success"
@@ -150,6 +150,7 @@ class ThreadifySpanExporter(_SpanExporterBase):
150
150
  "threadify.step_name",
151
151
  "threadify.role",
152
152
  "threadify.service",
153
+ "threadify.tags",
153
154
  }:
154
155
  continue
155
156
 
@@ -169,7 +170,7 @@ class ThreadifySpanExporter(_SpanExporterBase):
169
170
  if context:
170
171
  step.add_context(context)
171
172
  if refs:
172
- step.add_refs(refs)
173
+ await thread.add_refs(refs)
173
174
 
174
175
  # Map timing (OTel uses nanoseconds since epoch)
175
176
  start_time_ns = span.start_time
@@ -228,8 +229,6 @@ class ThreadifySpanExporter(_SpanExporterBase):
228
229
 
229
230
  async def _get_or_start_thread(self, span: ReadableSpan, trace_id: str) -> Any:
230
231
  """Get or create a ThreadInstance for this trace."""
231
- from threadify.thread import ThreadInstance
232
-
233
232
  if trace_id not in self._trace_threads:
234
233
  fut: asyncio.Future[Any] = asyncio.get_event_loop().create_future()
235
234
  self._trace_threads[trace_id] = fut
@@ -264,10 +263,12 @@ class ThreadifySpanExporter(_SpanExporterBase):
264
263
  except Exception:
265
264
  pass
266
265
 
266
+ tags = self._span_attr_list(span, "threadify.tags")
267
267
  thread = await self._connection.start(
268
268
  label=label,
269
269
  contract_name=contract_name or "",
270
270
  service_name=service_name,
271
+ tags=tags,
271
272
  )
272
273
  fut.set_result(thread)
273
274
  except Exception as exc:
@@ -298,6 +299,18 @@ class ThreadifySpanExporter(_SpanExporterBase):
298
299
  value = span.attributes.get(key)
299
300
  return str(value) if value is not None else None
300
301
 
302
+ @staticmethod
303
+ def _span_attr_list(span: ReadableSpan, key: str) -> list[str] | None:
304
+ value = span.attributes.get(key)
305
+ if value is None:
306
+ return None
307
+ if isinstance(value, list):
308
+ return [str(v) for v in value]
309
+ if isinstance(value, str):
310
+ # Allow comma-separated tags as a fallback
311
+ return [v.strip() for v in value.split(",") if v.strip()]
312
+ return None
313
+
301
314
  @staticmethod
302
315
  def _make_result(code: int, error: str | None = None) -> Any:
303
316
  """Build an OpenTelemetry ExportResult-compatible object."""
@@ -13,7 +13,6 @@ from threadify.models import (
13
13
  FIELD_IDEMPOTENCY_KEY,
14
14
  FIELD_IS_DUPLICATE,
15
15
  FIELD_MESSAGE,
16
- FIELD_REFS,
17
16
  FIELD_SERVICE_NAME,
18
17
  FIELD_STARTED_AT,
19
18
  FIELD_STATUS,
@@ -41,7 +40,6 @@ class ThreadStep:
41
40
  result = await (
42
41
  step
43
42
  .add_context({"orderId": "ORD-123", "amount": 99.99})
44
- .add_refs({"stripe_id": "pi_abc"})
45
43
  .success("Order placed!")
46
44
  )
47
45
  """
@@ -59,7 +57,6 @@ class ThreadStep:
59
57
  self._manual_idempotency_key: str = ""
60
58
  self._sub_steps: list[SubStepData] = []
61
59
  self._context: dict[str, str] = {}
62
- self._refs: dict[str, str] = {}
63
60
  self._metadata: dict[str, Any] | None = None
64
61
  self._error: Exception | None = None
65
62
 
@@ -108,14 +105,6 @@ class ThreadStep:
108
105
  self._context[f"private_{k}"] = s
109
106
  return self
110
107
 
111
- def add_refs(self, refs: dict[str, str] | None) -> ThreadStep:
112
- """Add external system references."""
113
- if self._error is not None:
114
- return self
115
- if refs:
116
- self._refs.update(refs)
117
- return self
118
-
119
108
  def sub_step(
120
109
  self,
121
110
  name: str,
@@ -181,7 +170,6 @@ class ThreadStep:
181
170
  self._event[FIELD_FINISHED_AT] = now_iso()
182
171
  self._event[FIELD_STATUS] = status
183
172
  self._event[FIELD_CONTEXT] = self._context
184
- self._event[FIELD_REFS] = self._refs
185
173
 
186
174
  # Handle optional message/data.
187
175
  if message_or_data is not None:
@@ -71,6 +71,7 @@ class ThreadInstance:
71
71
  self.role = role
72
72
  self.access_level = access_level
73
73
  self.refs: dict[str, str] = refs or {}
74
+ self.tags: list[str] = [] # Tags applied at thread creation (immutable)
74
75
 
75
76
  self._steps: dict[str, Any] = {}
76
77
  self._pending_waits: dict[str, _PendingWait] = {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: threadify-sdk
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: Python SDK for Threadify — Service-delivery intelligence. Track every customer request from start to finish across every system, team, and partner. Visit: https://threadify.dev
5
5
  Author-email: Threadify Team <team@threadify.dev>
6
6
  License: MIT
@@ -63,7 +63,14 @@ async def main():
63
63
  return
64
64
 
65
65
  try:
66
- thread = await conn.start("Order-123")
66
+ thread = await conn.start(
67
+ contract_name="order_processing",
68
+ role="customer",
69
+ refs={"order_id": "ORD-123"},
70
+ tags=["priority"],
71
+ )
72
+
73
+ await thread.add_refs({"crm_id": "CRM-456"})
67
74
 
68
75
  # Easy chaining!
69
76
  await (
@@ -130,6 +137,22 @@ thread = await conn.join(
130
137
  )
131
138
  ```
132
139
 
140
+ ### Start options
141
+
142
+ `Connection.start(...)` supports named options for contract, role, refs, and tags, while preserving the older positional forms:
143
+
144
+ ```python
145
+ thread = await conn.start(
146
+ contract_name="order_processing",
147
+ role="customer",
148
+ refs={"customer_id": "123"},
149
+ tags=["priority"],
150
+ )
151
+ thread = await conn.start("Order-123", "customer")
152
+ thread = await conn.start({"customer_id": "123"}, "customer")
153
+ thread = await conn.start("Order-123") # contract is optional
154
+ ```
155
+
133
156
  ## Subscriptions
134
157
 
135
158
  Preferred API:
File without changes
File without changes
File without changes