cortexdb-connectors 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,460 @@
1
+ """
2
+ ServiceNow connector for CortexDB.
3
+
4
+ Polls ServiceNow Table API for Incidents, Change Requests, Problems,
5
+ Knowledge Articles, and Tasks, converting them into CortexDB Episodes.
6
+ Uses ``httpx`` for direct HTTP access with offset-based pagination
7
+ (``sysparm_offset``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from cortexdb_connectors.base import (
19
+ Actor,
20
+ CortexConnector,
21
+ EntityRef,
22
+ Episode,
23
+ EpisodeType,
24
+ RawEvent,
25
+ Source,
26
+ Visibility,
27
+ VisibilityLevel,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ _SOURCE = Source(system="servicenow")
33
+
34
+ _DEFAULT_TABLES = frozenset(
35
+ ["incident", "change_request", "problem", "kb_knowledge", "sc_task"]
36
+ )
37
+
38
+ _TABLE_TYPE_MAP: dict[str, EpisodeType] = {
39
+ "incident": EpisodeType.INCIDENT,
40
+ "change_request": EpisodeType.DEPLOYMENT,
41
+ "problem": EpisodeType.ISSUE,
42
+ "kb_knowledge": EpisodeType.DOCUMENT,
43
+ "sc_task": EpisodeType.ISSUE,
44
+ }
45
+
46
+ _MAX_RETRIES = 3
47
+ _PAGE_SIZE = 100
48
+
49
+
50
+ class ServiceNowConnector(CortexConnector):
51
+ """Connector that syncs ServiceNow records into CortexDB.
52
+
53
+ Parameters
54
+ ----------
55
+ cortex_url:
56
+ Base URL of the CortexDB API.
57
+ cortex_api_key:
58
+ Bearer token for CortexDB authentication.
59
+ instance:
60
+ ServiceNow instance name (e.g. ``mycompany`` for
61
+ mycompany.service-now.com) or full URL.
62
+ username:
63
+ ServiceNow username for basic auth.
64
+ password:
65
+ ServiceNow password for basic auth.
66
+ tables:
67
+ ServiceNow table names to ingest. Defaults to incident,
68
+ change_request, problem, kb_knowledge, sc_task.
69
+ namespace:
70
+ CortexDB namespace for ingested episodes.
71
+ backfill_days:
72
+ Number of days of historical data to backfill on first sync.
73
+ tenant_id:
74
+ CortexDB tenant identifier.
75
+ """
76
+
77
+ connector_name: str = "servicenow"
78
+
79
+ def __init__(
80
+ self,
81
+ cortex_url: str,
82
+ cortex_api_key: str,
83
+ instance: str,
84
+ username: str,
85
+ password: str,
86
+ tables: list[str] | None = None,
87
+ namespace: str = "servicenow",
88
+ backfill_days: int = 90,
89
+ tenant_id: str = "default",
90
+ ) -> None:
91
+ super().__init__(cortex_url, cortex_api_key, tenant_id)
92
+ # Accept either "mycompany" or "https://mycompany.service-now.com".
93
+ if instance.startswith("http"):
94
+ self._base_url = instance.rstrip("/")
95
+ else:
96
+ self._base_url = f"https://{instance}.service-now.com"
97
+ self.username = username
98
+ self.password = password
99
+ self.table_filter = set(tables) if tables else set(_DEFAULT_TABLES)
100
+ self.namespace = namespace
101
+ self.backfill_days = backfill_days
102
+
103
+ # -- HTTP helpers --------------------------------------------------------
104
+
105
+ def _auth(self) -> tuple[str, str]:
106
+ return self.username, self.password
107
+
108
+ async def _request(
109
+ self,
110
+ client: httpx.AsyncClient,
111
+ url: str,
112
+ params: dict[str, Any] | None = None,
113
+ ) -> httpx.Response:
114
+ """GET request with rate-limit retry."""
115
+ for attempt in range(_MAX_RETRIES):
116
+ resp = await client.get(url, params=params)
117
+ if resp.status_code == 429:
118
+ import asyncio
119
+
120
+ retry_after = int(resp.headers.get("Retry-After", "10"))
121
+ logger.warning(
122
+ "ServiceNow rate limited, retrying in %ds (attempt %d/%d)",
123
+ retry_after,
124
+ attempt + 1,
125
+ _MAX_RETRIES,
126
+ )
127
+ await asyncio.sleep(retry_after)
128
+ continue
129
+ resp.raise_for_status()
130
+ return resp
131
+ raise httpx.HTTPStatusError(
132
+ "Rate limit retries exhausted",
133
+ request=resp.request, # type: ignore[possibly-undefined]
134
+ response=resp, # type: ignore[possibly-undefined]
135
+ )
136
+
137
+ # -- CortexConnector interface -------------------------------------------
138
+
139
+ async def fetch_events(
140
+ self, since: datetime | None = None
141
+ ) -> list[RawEvent]:
142
+ """Fetch records from configured ServiceNow tables.
143
+
144
+ Uses the Table API with ``sysparm_query`` for incremental sync
145
+ and ``sysparm_offset`` / ``sysparm_limit`` for pagination.
146
+ """
147
+ cutoff = since or datetime.now(timezone.utc) - timedelta(
148
+ days=self.backfill_days
149
+ )
150
+ raw_events: list[RawEvent] = []
151
+
152
+ async with httpx.AsyncClient(
153
+ auth=self._auth(),
154
+ headers={"Accept": "application/json"},
155
+ timeout=30.0,
156
+ ) as client:
157
+ for table in self.table_filter:
158
+ try:
159
+ raw_events.extend(
160
+ await self._fetch_table(client, table, cutoff)
161
+ )
162
+ except Exception:
163
+ logger.exception(
164
+ "Failed to fetch ServiceNow table %s", table
165
+ )
166
+ continue
167
+
168
+ return raw_events
169
+
170
+ def normalize(self, raw: RawEvent) -> Episode:
171
+ """Convert a ServiceNow ``RawEvent`` into a CortexDB ``Episode``."""
172
+ data = raw.data
173
+ table = data.get("table", "unknown")
174
+ record = data.get("record", {})
175
+ episode_type = _TABLE_TYPE_MAP.get(table, EpisodeType.CUSTOM)
176
+
177
+ actor = self._extract_actor(record)
178
+ content = self._build_content(table, record)
179
+ entities = self._extract_entities(table, record)
180
+ tags = ["servicenow", table]
181
+
182
+ state = record.get("state", "")
183
+ priority = record.get("priority", "")
184
+
185
+ metadata: dict[str, Any] = {
186
+ "servicenow_table": table,
187
+ "sys_id": record.get("sys_id", ""),
188
+ "number": record.get("number", ""),
189
+ }
190
+ if state:
191
+ metadata["state"] = state
192
+ if priority:
193
+ metadata["priority"] = priority
194
+ if record.get("category"):
195
+ metadata["category"] = record["category"]
196
+ if record.get("impact"):
197
+ metadata["impact"] = record["impact"]
198
+ if record.get("urgency"):
199
+ metadata["urgency"] = record["urgency"]
200
+
201
+ thread_id = self._derive_thread_id(table, record)
202
+
203
+ return Episode(
204
+ actor=actor,
205
+ source=_SOURCE,
206
+ episode_type=episode_type,
207
+ occurred_at=raw.timestamp,
208
+ content=content,
209
+ raw_payload=data,
210
+ tenant_id=self.tenant_id,
211
+ namespace=self.namespace,
212
+ entities=entities,
213
+ tags=tags,
214
+ metadata=metadata,
215
+ thread_id=thread_id,
216
+ idempotency_key=f"snow:{table}:{record.get('sys_id', raw.external_id)}",
217
+ )
218
+
219
+ def map_permissions(self, raw: RawEvent) -> Visibility:
220
+ """Map ServiceNow record visibility.
221
+
222
+ Knowledge articles marked as public are ``Organization``;
223
+ everything else is ``Restricted`` by default since ServiceNow
224
+ typically contains internal operational data.
225
+ """
226
+ data = raw.data
227
+ table = data.get("table", "")
228
+ record = data.get("record", {})
229
+
230
+ if table == "kb_knowledge":
231
+ workflow_state = record.get("workflow_state", "")
232
+ if workflow_state == "published":
233
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
234
+ return Visibility(level=VisibilityLevel.RESTRICTED)
235
+
236
+ return Visibility(level=VisibilityLevel.ORGANIZATION)
237
+
238
+ # -- fetchers ------------------------------------------------------------
239
+
240
+ async def _fetch_table(
241
+ self,
242
+ client: httpx.AsyncClient,
243
+ table: str,
244
+ cutoff: datetime,
245
+ ) -> list[RawEvent]:
246
+ """Fetch all records from a ServiceNow table since cutoff."""
247
+ events: list[RawEvent] = []
248
+ cutoff_str = cutoff.strftime("%Y-%m-%d %H:%M:%S")
249
+ fields = self._fields_for(table)
250
+ offset = 0
251
+
252
+ while True:
253
+ params: dict[str, Any] = {
254
+ "sysparm_query": (
255
+ f"sys_updated_on>{cutoff_str}^ORDERBYDESCsys_updated_on"
256
+ ),
257
+ "sysparm_fields": fields,
258
+ "sysparm_limit": _PAGE_SIZE,
259
+ "sysparm_offset": offset,
260
+ "sysparm_display_value": "false",
261
+ }
262
+
263
+ try:
264
+ resp = await self._request(
265
+ client,
266
+ f"{self._base_url}/api/now/table/{table}",
267
+ params=params,
268
+ )
269
+ except Exception:
270
+ logger.exception(
271
+ "Failed to fetch page %d for table %s", offset, table
272
+ )
273
+ break
274
+
275
+ body = resp.json()
276
+ records = body.get("result", [])
277
+ if not records:
278
+ break
279
+
280
+ for rec in records:
281
+ ts = self._parse_snow_ts(rec.get("sys_updated_on"))
282
+ events.append(
283
+ RawEvent(
284
+ source="servicenow",
285
+ external_id=rec.get("sys_id", ""),
286
+ timestamp=ts,
287
+ data={
288
+ "table": table,
289
+ "record": rec,
290
+ },
291
+ permissions={
292
+ "assigned_to": rec.get("assigned_to", ""),
293
+ "assignment_group": rec.get("assignment_group", ""),
294
+ },
295
+ )
296
+ )
297
+
298
+ if len(records) < _PAGE_SIZE:
299
+ break
300
+ offset += _PAGE_SIZE
301
+
302
+ return events
303
+
304
+ # -- helpers -------------------------------------------------------------
305
+
306
+ @staticmethod
307
+ def _fields_for(table: str) -> str:
308
+ """Return the field list for a ServiceNow table query."""
309
+ base = "sys_id,sys_updated_on,sys_created_on,opened_by,assigned_to,assignment_group"
310
+ table_fields: dict[str, str] = {
311
+ "incident": (
312
+ f"{base},number,short_description,description,state,"
313
+ "priority,urgency,impact,category,subcategory,"
314
+ "cmdb_ci,caller_id,resolved_by,close_code"
315
+ ),
316
+ "change_request": (
317
+ f"{base},number,short_description,description,state,"
318
+ "priority,risk,impact,type,category,"
319
+ "start_date,end_date,cmdb_ci"
320
+ ),
321
+ "problem": (
322
+ f"{base},number,short_description,description,state,"
323
+ "priority,urgency,impact,category,"
324
+ "known_error,cause_notes,fix_notes"
325
+ ),
326
+ "kb_knowledge": (
327
+ "sys_id,sys_updated_on,sys_created_on,"
328
+ "number,short_description,text,workflow_state,"
329
+ "kb_category,author,published"
330
+ ),
331
+ "sc_task": (
332
+ f"{base},number,short_description,description,state,"
333
+ "priority,request_item,due_date"
334
+ ),
335
+ }
336
+ return table_fields.get(table, base + ",number,short_description,description,state")
337
+
338
+ @staticmethod
339
+ def _build_content(table: str, record: dict[str, Any]) -> str:
340
+ """Build human-readable content from a ServiceNow record."""
341
+ number = record.get("number", "")
342
+ short_desc = record.get("short_description", "Untitled")
343
+ desc = (record.get("description") or record.get("text") or "")[:1000]
344
+ state = record.get("state", "")
345
+
346
+ label_map: dict[str, str] = {
347
+ "incident": "Incident",
348
+ "change_request": "Change Request",
349
+ "problem": "Problem",
350
+ "kb_knowledge": "Knowledge Article",
351
+ "sc_task": "Task",
352
+ }
353
+ label = label_map.get(table, table.title())
354
+
355
+ if table == "kb_knowledge":
356
+ workflow = record.get("workflow_state", "")
357
+ return f"{label} [{workflow}]: {short_desc}\n{desc}".strip()
358
+
359
+ return f"{label} {number} [{state}]: {short_desc}\n{desc}".strip()
360
+
361
+ @staticmethod
362
+ def _extract_actor(record: dict[str, Any]) -> Actor:
363
+ """Extract actor from a ServiceNow record."""
364
+ opened_by = record.get("opened_by") or record.get("author") or ""
365
+ assigned_to = record.get("assigned_to") or ""
366
+ actor_id = opened_by or assigned_to or "system"
367
+ return Actor(id=str(actor_id), name=str(actor_id))
368
+
369
+ @staticmethod
370
+ def _extract_entities(
371
+ table: str, record: dict[str, Any]
372
+ ) -> list[EntityRef]:
373
+ """Extract entity references from a ServiceNow record."""
374
+ entities: list[EntityRef] = []
375
+
376
+ sys_id = record.get("sys_id", "")
377
+ number = record.get("number", "")
378
+ if sys_id:
379
+ entities.append(
380
+ EntityRef(
381
+ entity_type=table,
382
+ entity_id=sys_id,
383
+ display_name=number or sys_id,
384
+ )
385
+ )
386
+
387
+ cmdb_ci = record.get("cmdb_ci")
388
+ if cmdb_ci:
389
+ entities.append(
390
+ EntityRef(
391
+ entity_type="configuration_item",
392
+ entity_id=str(cmdb_ci),
393
+ display_name=str(cmdb_ci),
394
+ )
395
+ )
396
+
397
+ assigned_to = record.get("assigned_to")
398
+ if assigned_to:
399
+ entities.append(
400
+ EntityRef(
401
+ entity_type="user",
402
+ entity_id=str(assigned_to),
403
+ display_name=str(assigned_to),
404
+ )
405
+ )
406
+
407
+ group = record.get("assignment_group")
408
+ if group:
409
+ entities.append(
410
+ EntityRef(
411
+ entity_type="group",
412
+ entity_id=str(group),
413
+ display_name=str(group),
414
+ )
415
+ )
416
+
417
+ category = record.get("category") or record.get("kb_category")
418
+ if category:
419
+ entities.append(
420
+ EntityRef(
421
+ entity_type="category",
422
+ entity_id=str(category),
423
+ display_name=str(category),
424
+ )
425
+ )
426
+
427
+ return entities
428
+
429
+ @staticmethod
430
+ def _derive_thread_id(
431
+ table: str, record: dict[str, Any]
432
+ ) -> str | None:
433
+ """Derive a thread_id for grouping related records."""
434
+ sys_id = record.get("sys_id", "")
435
+ if not sys_id:
436
+ return None
437
+
438
+ # Tasks thread to their parent request item.
439
+ if table == "sc_task":
440
+ request_item = record.get("request_item")
441
+ if request_item:
442
+ return f"snow:request:{request_item}"
443
+
444
+ return f"snow:{table}:{sys_id}"
445
+
446
+ @staticmethod
447
+ def _parse_snow_ts(value: str | None) -> datetime:
448
+ """Parse a ServiceNow datetime string (``YYYY-MM-DD HH:MM:SS``)."""
449
+ if not value:
450
+ return datetime.now(timezone.utc)
451
+ try:
452
+ return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(
453
+ tzinfo=timezone.utc
454
+ )
455
+ except (ValueError, TypeError):
456
+ pass
457
+ try:
458
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
459
+ except (ValueError, TypeError):
460
+ return datetime.now(timezone.utc)