canvas 0.63.0__py3-none-any.whl → 0.89.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.
Files changed (185) hide show
  1. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/METADATA +4 -1
  2. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/RECORD +184 -98
  3. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/WHEEL +1 -1
  4. canvas_cli/apps/emit/event_fixtures/UNKNOWN.ndjson +1 -0
  5. canvas_cli/apps/logs/logs.py +386 -22
  6. canvas_cli/main.py +3 -1
  7. canvas_cli/templates/plugins/default/{{ cookiecutter.__project_slug }}/tests/test_models.py +46 -4
  8. canvas_cli/utils/context/context.py +13 -13
  9. canvas_cli/utils/validators/manifest_schema.py +26 -1
  10. canvas_generated/messages/effects_pb2.py +5 -5
  11. canvas_generated/messages/effects_pb2.pyi +108 -2
  12. canvas_generated/messages/events_pb2.py +6 -6
  13. canvas_generated/messages/events_pb2.pyi +282 -2
  14. canvas_sdk/clients/__init__.py +1 -0
  15. canvas_sdk/clients/llms/__init__.py +17 -0
  16. canvas_sdk/clients/llms/libraries/__init__.py +11 -0
  17. canvas_sdk/clients/llms/libraries/llm_anthropic.py +87 -0
  18. canvas_sdk/clients/llms/libraries/llm_api.py +143 -0
  19. canvas_sdk/clients/llms/libraries/llm_google.py +92 -0
  20. canvas_sdk/clients/llms/libraries/llm_openai.py +98 -0
  21. canvas_sdk/clients/llms/structures/__init__.py +9 -0
  22. canvas_sdk/clients/llms/structures/llm_response.py +33 -0
  23. canvas_sdk/clients/llms/structures/llm_tokens.py +53 -0
  24. canvas_sdk/clients/llms/structures/llm_turn.py +47 -0
  25. canvas_sdk/clients/llms/structures/settings/__init__.py +13 -0
  26. canvas_sdk/clients/llms/structures/settings/llm_settings.py +27 -0
  27. canvas_sdk/clients/llms/structures/settings/llm_settings_anthropic.py +43 -0
  28. canvas_sdk/clients/llms/structures/settings/llm_settings_gemini.py +40 -0
  29. canvas_sdk/clients/llms/structures/settings/llm_settings_gpt4.py +40 -0
  30. canvas_sdk/clients/llms/structures/settings/llm_settings_gpt5.py +48 -0
  31. canvas_sdk/clients/third_party.py +3 -0
  32. canvas_sdk/commands/__init__.py +12 -0
  33. canvas_sdk/commands/base.py +33 -2
  34. canvas_sdk/commands/commands/adjust_prescription.py +4 -0
  35. canvas_sdk/commands/commands/custom_command.py +86 -0
  36. canvas_sdk/commands/commands/family_history.py +17 -1
  37. canvas_sdk/commands/commands/immunization_statement.py +42 -2
  38. canvas_sdk/commands/commands/medication_statement.py +16 -1
  39. canvas_sdk/commands/commands/past_surgical_history.py +16 -1
  40. canvas_sdk/commands/commands/perform.py +18 -1
  41. canvas_sdk/commands/commands/prescribe.py +8 -9
  42. canvas_sdk/commands/commands/refill.py +5 -5
  43. canvas_sdk/commands/commands/resolve_condition.py +5 -5
  44. canvas_sdk/commands/commands/review/__init__.py +3 -0
  45. canvas_sdk/commands/commands/review/base.py +72 -0
  46. canvas_sdk/commands/commands/review/imaging.py +13 -0
  47. canvas_sdk/commands/commands/review/lab.py +13 -0
  48. canvas_sdk/commands/commands/review/referral.py +13 -0
  49. canvas_sdk/commands/commands/review/uncategorized_document.py +13 -0
  50. canvas_sdk/commands/validation.py +43 -0
  51. canvas_sdk/effects/batch_originate.py +22 -0
  52. canvas_sdk/effects/calendar/__init__.py +13 -3
  53. canvas_sdk/effects/calendar/{create_calendar.py → calendar.py} +19 -5
  54. canvas_sdk/effects/calendar/event.py +172 -0
  55. canvas_sdk/effects/claim_label.py +93 -0
  56. canvas_sdk/effects/claim_line_item.py +47 -0
  57. canvas_sdk/effects/claim_queue.py +49 -0
  58. canvas_sdk/effects/fax/__init__.py +3 -0
  59. canvas_sdk/effects/fax/base.py +77 -0
  60. canvas_sdk/effects/fax/note.py +42 -0
  61. canvas_sdk/effects/metadata.py +15 -1
  62. canvas_sdk/effects/note/__init__.py +8 -1
  63. canvas_sdk/effects/note/appointment.py +135 -7
  64. canvas_sdk/effects/note/base.py +17 -0
  65. canvas_sdk/effects/note/message.py +22 -14
  66. canvas_sdk/effects/note/note.py +150 -1
  67. canvas_sdk/effects/observation/__init__.py +11 -0
  68. canvas_sdk/effects/observation/base.py +206 -0
  69. canvas_sdk/effects/patient/__init__.py +2 -0
  70. canvas_sdk/effects/patient/base.py +8 -0
  71. canvas_sdk/effects/payment/__init__.py +11 -0
  72. canvas_sdk/effects/payment/base.py +355 -0
  73. canvas_sdk/effects/payment/post_claim_payment.py +49 -0
  74. canvas_sdk/effects/send_contact_verification.py +42 -0
  75. canvas_sdk/effects/task/__init__.py +2 -1
  76. canvas_sdk/effects/task/task.py +30 -0
  77. canvas_sdk/effects/validation/__init__.py +3 -0
  78. canvas_sdk/effects/validation/base.py +92 -0
  79. canvas_sdk/events/base.py +15 -0
  80. canvas_sdk/handlers/application.py +7 -7
  81. canvas_sdk/handlers/simple_api/api.py +1 -4
  82. canvas_sdk/handlers/simple_api/websocket.py +1 -4
  83. canvas_sdk/handlers/utils.py +14 -0
  84. canvas_sdk/questionnaires/utils.py +1 -0
  85. canvas_sdk/templates/utils.py +17 -4
  86. canvas_sdk/test_utils/factories/FACTORY_GUIDE.md +362 -0
  87. canvas_sdk/test_utils/factories/__init__.py +115 -0
  88. canvas_sdk/test_utils/factories/calendar.py +24 -0
  89. canvas_sdk/test_utils/factories/claim.py +81 -0
  90. canvas_sdk/test_utils/factories/claim_diagnosis_code.py +16 -0
  91. canvas_sdk/test_utils/factories/coverage.py +17 -0
  92. canvas_sdk/test_utils/factories/imaging.py +74 -0
  93. canvas_sdk/test_utils/factories/lab.py +192 -0
  94. canvas_sdk/test_utils/factories/medication_history.py +75 -0
  95. canvas_sdk/test_utils/factories/note.py +52 -0
  96. canvas_sdk/test_utils/factories/organization.py +50 -0
  97. canvas_sdk/test_utils/factories/practicelocation.py +88 -0
  98. canvas_sdk/test_utils/factories/referral.py +81 -0
  99. canvas_sdk/test_utils/factories/staff.py +111 -0
  100. canvas_sdk/test_utils/factories/task.py +66 -0
  101. canvas_sdk/test_utils/factories/uncategorized_clinical_document.py +48 -0
  102. canvas_sdk/utils/metrics.py +4 -1
  103. canvas_sdk/v1/data/__init__.py +66 -7
  104. canvas_sdk/v1/data/allergy_intolerance.py +5 -11
  105. canvas_sdk/v1/data/appointment.py +18 -4
  106. canvas_sdk/v1/data/assessment.py +2 -12
  107. canvas_sdk/v1/data/banner_alert.py +2 -4
  108. canvas_sdk/v1/data/base.py +53 -14
  109. canvas_sdk/v1/data/billing.py +8 -11
  110. canvas_sdk/v1/data/calendar.py +64 -0
  111. canvas_sdk/v1/data/care_team.py +4 -10
  112. canvas_sdk/v1/data/claim.py +172 -66
  113. canvas_sdk/v1/data/claim_diagnosis_code.py +19 -0
  114. canvas_sdk/v1/data/claim_line_item.py +2 -5
  115. canvas_sdk/v1/data/coding.py +19 -0
  116. canvas_sdk/v1/data/command.py +2 -4
  117. canvas_sdk/v1/data/common.py +10 -0
  118. canvas_sdk/v1/data/compound_medication.py +3 -4
  119. canvas_sdk/v1/data/condition.py +4 -9
  120. canvas_sdk/v1/data/coverage.py +66 -26
  121. canvas_sdk/v1/data/detected_issue.py +20 -20
  122. canvas_sdk/v1/data/device.py +2 -14
  123. canvas_sdk/v1/data/discount.py +2 -5
  124. canvas_sdk/v1/data/encounter.py +44 -0
  125. canvas_sdk/v1/data/facility.py +1 -0
  126. canvas_sdk/v1/data/goal.py +2 -14
  127. canvas_sdk/v1/data/imaging.py +4 -30
  128. canvas_sdk/v1/data/immunization.py +7 -15
  129. canvas_sdk/v1/data/lab.py +12 -65
  130. canvas_sdk/v1/data/line_item_transaction.py +2 -5
  131. canvas_sdk/v1/data/medication.py +3 -8
  132. canvas_sdk/v1/data/medication_history.py +142 -0
  133. canvas_sdk/v1/data/medication_statement.py +41 -0
  134. canvas_sdk/v1/data/message.py +4 -8
  135. canvas_sdk/v1/data/note.py +37 -38
  136. canvas_sdk/v1/data/observation.py +9 -36
  137. canvas_sdk/v1/data/organization.py +70 -9
  138. canvas_sdk/v1/data/patient.py +8 -12
  139. canvas_sdk/v1/data/patient_consent.py +4 -14
  140. canvas_sdk/v1/data/payment_collection.py +2 -5
  141. canvas_sdk/v1/data/posting.py +3 -9
  142. canvas_sdk/v1/data/practicelocation.py +66 -7
  143. canvas_sdk/v1/data/protocol_override.py +3 -4
  144. canvas_sdk/v1/data/protocol_result.py +3 -3
  145. canvas_sdk/v1/data/questionnaire.py +10 -26
  146. canvas_sdk/v1/data/reason_for_visit.py +2 -6
  147. canvas_sdk/v1/data/referral.py +41 -17
  148. canvas_sdk/v1/data/staff.py +34 -26
  149. canvas_sdk/v1/data/stop_medication_event.py +27 -0
  150. canvas_sdk/v1/data/task.py +30 -11
  151. canvas_sdk/v1/data/team.py +2 -4
  152. canvas_sdk/v1/data/uncategorized_clinical_document.py +84 -0
  153. canvas_sdk/v1/data/user.py +14 -0
  154. canvas_sdk/v1/data/utils.py +5 -0
  155. canvas_sdk/value_set/v2026/__init__.py +1 -0
  156. canvas_sdk/value_set/v2026/adverse_event.py +157 -0
  157. canvas_sdk/value_set/v2026/allergy.py +116 -0
  158. canvas_sdk/value_set/v2026/assessment.py +466 -0
  159. canvas_sdk/value_set/v2026/communication.py +496 -0
  160. canvas_sdk/value_set/v2026/condition.py +52934 -0
  161. canvas_sdk/value_set/v2026/device.py +315 -0
  162. canvas_sdk/value_set/v2026/diagnostic_study.py +5243 -0
  163. canvas_sdk/value_set/v2026/encounter.py +2714 -0
  164. canvas_sdk/value_set/v2026/immunization.py +297 -0
  165. canvas_sdk/value_set/v2026/individual_characteristic.py +339 -0
  166. canvas_sdk/value_set/v2026/intervention.py +1703 -0
  167. canvas_sdk/value_set/v2026/laboratory_test.py +1831 -0
  168. canvas_sdk/value_set/v2026/medication.py +8218 -0
  169. canvas_sdk/value_set/v2026/no_qdm_category_assigned.py +26493 -0
  170. canvas_sdk/value_set/v2026/physical_exam.py +342 -0
  171. canvas_sdk/value_set/v2026/procedure.py +27869 -0
  172. canvas_sdk/value_set/v2026/symptom.py +625 -0
  173. logger/logger.py +30 -31
  174. logger/logstash.py +282 -0
  175. logger/pubsub.py +26 -0
  176. plugin_runner/allowed-module-imports.json +940 -9
  177. plugin_runner/generate_allowed_imports.py +1 -0
  178. plugin_runner/installation.py +2 -2
  179. plugin_runner/plugin_runner.py +21 -24
  180. plugin_runner/sandbox.py +34 -0
  181. protobufs/canvas_generated/messages/effects.proto +65 -0
  182. protobufs/canvas_generated/messages/events.proto +150 -51
  183. settings.py +27 -11
  184. canvas_sdk/effects/calendar/create_event.py +0 -43
  185. {canvas-0.63.0.dist-info → canvas-0.89.0.dist-info}/entry_points.txt +0 -0
logger/logger.py CHANGED
@@ -2,26 +2,11 @@ import logging
2
2
  import os
3
3
  from typing import Any
4
4
 
5
- import redis
5
+ from django.conf import settings
6
+ from logstash_async.handler import AsynchronousLogstashHandler
6
7
 
7
- from pubsub.pubsub import Publisher
8
-
9
-
10
- class PubSubLogHandler(logging.Handler):
11
- """Custom logging handler that publishes logs to a pub/sub channel."""
12
-
13
- def __init__(self) -> None:
14
- self.publisher = Publisher()
15
- logging.Handler.__init__(self=self)
16
-
17
- def emit(self, record: Any) -> None:
18
- """Publishes the log message to the pub/sub channel."""
19
- message = self.format(record)
20
-
21
- try:
22
- self.publisher.publish(message)
23
- except redis.ConnectionError as e:
24
- print(f"PubSubLogHandler: failed to log message due to redis error: {e}")
8
+ from logger.logstash import LogstashFormatterECS
9
+ from logger.pubsub import PubSubLogHandler
25
10
 
26
11
 
27
12
  class PluginLogger:
@@ -31,13 +16,13 @@ class PluginLogger:
31
16
  self.logger = logging.getLogger("plugin_runner_logger")
32
17
  self.logger.setLevel(logging.INFO)
33
18
 
34
- log_prefix = os.getenv("HOSTNAME", "")
19
+ log_prefix = f"{os.getenv('HOSTNAME', '?')}: {os.getenv('APTIBLE_PROCESS_INDEX', '?')}"
35
20
 
36
21
  if log_prefix != "":
37
22
  log_prefix = f"[{log_prefix}] "
38
23
 
39
24
  formatter = logging.Formatter(
40
- f"plugin_runner {log_prefix}%(levelname)s %(asctime)s %(message)s"
25
+ f"plugin-runner {log_prefix}%(levelname)s %(asctime)s %(message)s"
41
26
  )
42
27
 
43
28
  streaming_handler = logging.StreamHandler()
@@ -45,29 +30,43 @@ class PluginLogger:
45
30
 
46
31
  self.logger.addHandler(streaming_handler)
47
32
 
48
- if os.getenv("REDIS_ENDPOINT"):
33
+ if settings.REDIS_ENDPOINT:
49
34
  pubsub_handler = PubSubLogHandler()
50
35
  pubsub_handler.setFormatter(formatter)
51
36
 
52
37
  self.logger.addHandler(pubsub_handler)
53
38
 
54
- def debug(self, message: Any) -> None:
39
+ if settings.LOGSTASH_HOST:
40
+ logstash_handler = AsynchronousLogstashHandler(
41
+ host=settings.LOGSTASH_HOST,
42
+ port=settings.LOGSTASH_PORT,
43
+ database_path=None,
44
+ transport=settings.LOGSTASH_PROTOCOL,
45
+ )
46
+ logstash_handler.setFormatter(LogstashFormatterECS())
47
+ self.logger.addHandler(logstash_handler)
48
+
49
+ def debug(self, message: Any, *args: Any, **kwargs: Any) -> None:
55
50
  """Logs a debug message."""
56
- self.logger.debug(message)
51
+ self.logger.debug(message, *args, **kwargs)
57
52
 
58
- def info(self, message: Any) -> None:
53
+ def info(self, message: Any, *args: Any, **kwargs: Any) -> None:
59
54
  """Logs an info message."""
60
- self.logger.info(message)
55
+ self.logger.info(message, *args, **kwargs)
61
56
 
62
- def warning(self, message: Any) -> None:
57
+ def warning(self, message: Any, *args: Any, **kwargs: Any) -> None:
63
58
  """Logs a warning message."""
64
- self.logger.warning(message)
59
+ self.logger.warning(message, *args, **kwargs)
65
60
 
66
- def error(self, message: Any) -> None:
61
+ def error(self, message: Any, *args: Any, **kwargs: Any) -> None:
67
62
  """Logs an error message."""
68
- self.logger.error(message)
63
+ self.logger.error(message, *args, **kwargs)
64
+
65
+ def exception(self, message: Any, *args: Any, **kwargs: Any) -> None:
66
+ """Convenience method for logging an ERROR with exception information."""
67
+ self.logger.exception(message, *args, **kwargs)
69
68
 
70
- def critical(self, message: Any) -> None:
69
+ def critical(self, message: Any, *args: Any, **kwargs: Any) -> None:
71
70
  """Logs a critical message."""
72
71
  self.logger.critical(message)
73
72
 
logger/logstash.py ADDED
@@ -0,0 +1,282 @@
1
+ import contextlib
2
+ import datetime
3
+ import json
4
+ import logging
5
+ import re
6
+ import sys
7
+ import traceback
8
+ from logging import LogRecord
9
+ from types import TracebackType
10
+ from typing import Any
11
+
12
+ import requests
13
+ from django.conf import settings
14
+
15
+
16
+ class HttpTransport:
17
+ """
18
+ Send messages to Logstash in V1 format.
19
+ """
20
+
21
+ def __init__(self, host: str, **kwargs: Any) -> None:
22
+ self.url = host
23
+
24
+ self.session = requests.Session()
25
+ self.session.headers.update({"Content-Type": "application/json"})
26
+
27
+ def send(self, events: list[Any], **kwargs: Any) -> None:
28
+ """Send events to Logstash."""
29
+ for event in events:
30
+ try:
31
+ self.session.post(self.url, data=event)
32
+ except (KeyboardInterrupt, SystemExit):
33
+ raise
34
+ except BaseException as e:
35
+ print("Logstash exception", e)
36
+
37
+ def close(self) -> None:
38
+ """Close the transport."""
39
+ pass
40
+
41
+
42
+ ExcInfo = tuple[type[BaseException], BaseException, TracebackType | None]
43
+
44
+
45
+ def _figure_out_exc_info(v: Any) -> ExcInfo:
46
+ """
47
+ Depending on the Python version will try to do the smartest thing possible
48
+ to transform *v* into an ``exc_info`` tuple.
49
+ """
50
+ if isinstance(v, BaseException):
51
+ return v.__class__, v, v.__traceback__
52
+
53
+ if isinstance(v, tuple):
54
+ return v
55
+
56
+ if v:
57
+ return sys.exc_info() # type: ignore[return-value]
58
+
59
+ return v
60
+
61
+
62
+ def _json_default(obj: Any) -> str:
63
+ """
64
+ Coerce everything to strings. All objects representing time get output as
65
+ ISO8601.
66
+ """
67
+ if isinstance(obj, datetime.datetime | datetime.date | datetime.time):
68
+ return obj.isoformat()
69
+
70
+ return str(obj)
71
+
72
+
73
+ class LogstashFormatterV1(logging.Formatter):
74
+ """
75
+ A custom formatter to prepare logs to be shipped out to logstash V1 format.
76
+ """
77
+
78
+ unwanted_fields = [
79
+ "args",
80
+ "created",
81
+ "filename",
82
+ "funcName",
83
+ "levelno",
84
+ "lineno",
85
+ "module",
86
+ "msecs",
87
+ "name",
88
+ "pathname",
89
+ "process",
90
+ "processName",
91
+ "relativeCreated",
92
+ "request",
93
+ "response",
94
+ "threadName",
95
+ ]
96
+
97
+ mappings = {
98
+ "levelname": "log_level",
99
+ "name": "log_name",
100
+ }
101
+
102
+ def __init__(self) -> None:
103
+ super().__init__()
104
+
105
+ self.defaults = {
106
+ "app": "plugin-runner",
107
+ "customer": settings.CUSTOMER_IDENTIFIER,
108
+ "plugin_context": True,
109
+ }
110
+
111
+ self.source_host = settings.HOSTNAME
112
+
113
+ @staticmethod
114
+ def get_location(fields: dict) -> str:
115
+ """
116
+ Format the log location in a way that saves some bytes.
117
+ """
118
+ path = "?"
119
+
120
+ if "pathname" in fields:
121
+ path = re.sub(r"^/plugin-runner/", "", fields["pathname"])
122
+ path = re.sub(r"^/usr/local/lib/python3.\d\d/dist-packages/", "", path)
123
+ path = re.sub(r"^/venvs/plugin-runner/lib/python3.\d\d/site-packages/", "", path)
124
+ elif "filename" in fields:
125
+ path = fields["filename"]
126
+
127
+ return "{}:{}".format(path, fields.get("lineno", "?"))
128
+
129
+ @staticmethod
130
+ def get_method(fields: dict) -> str:
131
+ """
132
+ Get the method that originated the log.
133
+ """
134
+ return "{}.{}:{}".format(
135
+ fields.get("name", "?"), fields.get("module", "?"), fields.get("funcName", "?")
136
+ )
137
+
138
+ def format(self, record: LogRecord) -> str:
139
+ """
140
+ Format a log record to JSON, if the message is a dict assume an empty
141
+ message and use the dict as additional fields.
142
+ """
143
+ fields = record.__dict__.copy()
144
+
145
+ if "msg" in fields and isinstance(fields["msg"], dict):
146
+ msg = fields.pop("msg")
147
+
148
+ # if the dict has an "event" key, use that as the message
149
+ # to avoid conflicts with other message types that use event as an object.
150
+ if "event" in msg:
151
+ fields["message"] = msg.pop("event")
152
+
153
+ fields.update(msg)
154
+ elif "msg" in fields and "message" not in fields:
155
+ msg = record.getMessage()
156
+
157
+ del fields["msg"]
158
+
159
+ with contextlib.suppress(KeyError, IndexError, ValueError):
160
+ msg = msg.format(**fields)
161
+
162
+ fields["message"] = msg
163
+
164
+ if "exc_info" in fields:
165
+ if fields["exc_info"]:
166
+ exc_info = _figure_out_exc_info(fields["exc_info"])
167
+ fields["exception_message"] = str(exc_info[1])
168
+ fields["exception_type"] = f"{exc_info[0].__module__}.{exc_info[0].__qualname__}"
169
+ fields["stack_trace"] = traceback.format_exception(*exc_info)
170
+ del fields["exc_info"]
171
+
172
+ if "exc_text" in fields and not fields["exc_text"]:
173
+ del fields["exc_text"]
174
+
175
+ now = datetime.datetime.now(datetime.UTC)
176
+
177
+ base_log = {
178
+ "@timestamp": now.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
179
+ "@version": 1,
180
+ "location": self.get_location(fields),
181
+ "method": self.get_method(fields),
182
+ "source_host": self.source_host,
183
+ }
184
+
185
+ for field in self.unwanted_fields:
186
+ if field in fields:
187
+ del fields[field]
188
+
189
+ # Remove None fields (like `params` most of the time) and empty
190
+ # argument lists (like `args` most of the time)
191
+ fields = {key: value for key, value in fields.items() if value is not None and value != []}
192
+
193
+ # Rename fields with mapping set
194
+ for old_name, new_name in self.mappings.items():
195
+ if old_name in fields:
196
+ fields[new_name] = fields.pop(old_name)
197
+
198
+ base_log.update(fields)
199
+
200
+ log_record = self.defaults.copy()
201
+ log_record.update(base_log)
202
+
203
+ return json.dumps(log_record, default=_json_default)
204
+
205
+
206
+ class LogstashFormatterECS(logging.Formatter):
207
+ """
208
+ A custom formatter to prepare logs to be shipped out to logstash ECS format.
209
+ """
210
+
211
+ def __init__(self) -> None:
212
+ super().__init__()
213
+
214
+ self.defaults = {
215
+ "service": {"name": "plugin-runner"},
216
+ "labels": {"customer": settings.CUSTOMER_IDENTIFIER},
217
+ "host": {"name": settings.HOSTNAME},
218
+ }
219
+
220
+ def format(self, record: LogRecord) -> str:
221
+ """
222
+ Format a log record to JSON, if the message is a dict assume an empty
223
+ message and use the dict as additional fields.
224
+ """
225
+ fields = record.__dict__.copy()
226
+
227
+ if "msg" in fields and isinstance(fields["msg"], dict):
228
+ msg = fields.pop("msg")
229
+
230
+ # if the dict has an "event" key, use that as the message
231
+ # to avoid conflicts with other message types that use event as an object.
232
+ if "event" in msg:
233
+ fields["message"] = msg.pop("event")
234
+
235
+ fields.update(msg)
236
+ elif "msg" in fields and "message" not in fields:
237
+ msg = record.getMessage()
238
+
239
+ del fields["msg"]
240
+
241
+ with contextlib.suppress(KeyError, IndexError, ValueError):
242
+ msg = msg.format(**fields)
243
+
244
+ fields["message"] = msg
245
+
246
+ if "exc_info" in fields:
247
+ if fields["exc_info"]:
248
+ exc_info = _figure_out_exc_info(fields["exc_info"])
249
+ fields["exception_message"] = str(exc_info[1])
250
+ fields["exception_type"] = f"{exc_info[0].__module__}.{exc_info[0].__qualname__}"
251
+ fields["stack_trace"] = traceback.format_exception(*exc_info)
252
+ del fields["exc_info"]
253
+
254
+ if "exc_text" in fields and not fields["exc_text"]:
255
+ del fields["exc_text"]
256
+
257
+ now = datetime.datetime.now(datetime.UTC)
258
+
259
+ log_record = {
260
+ **self.defaults,
261
+ "@timestamp": now.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
262
+ "message": fields.get("message", ""),
263
+ "log": {
264
+ "level": fields.get("levelname", ""),
265
+ },
266
+ **(
267
+ {
268
+ "error": {
269
+ "message": fields["exception_message"],
270
+ "type": fields["exception_type"],
271
+ "stack_trace": fields["stack_trace"],
272
+ }
273
+ }
274
+ if "exception_message" in fields
275
+ else {}
276
+ ),
277
+ }
278
+
279
+ return json.dumps(log_record, default=_json_default)
280
+
281
+
282
+ __exports__ = ()
logger/pubsub.py ADDED
@@ -0,0 +1,26 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ import redis
5
+
6
+ from pubsub.pubsub import Publisher
7
+
8
+
9
+ class PubSubLogHandler(logging.Handler):
10
+ """Custom logging handler that publishes logs to a pub/sub channel."""
11
+
12
+ def __init__(self) -> None:
13
+ self.publisher = Publisher()
14
+ logging.Handler.__init__(self=self)
15
+
16
+ def emit(self, record: Any) -> None:
17
+ """Publishes the log message to the pub/sub channel."""
18
+ message = self.format(record)
19
+
20
+ try:
21
+ self.publisher.publish(message)
22
+ except redis.ConnectionError as e:
23
+ print(f"PubSubLogHandler: failed to log message due to redis error: {e}")
24
+
25
+
26
+ __exports__ = ()