arize-phoenix 4.35.2__py3-none-any.whl → 5.0.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.

Potentially problematic release.


This version of arize-phoenix might be problematic. Click here for more details.

Files changed (104) hide show
  1. {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/METADATA +10 -12
  2. {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/RECORD +92 -79
  3. phoenix/__init__.py +86 -0
  4. phoenix/auth.py +275 -14
  5. phoenix/config.py +369 -27
  6. phoenix/db/alembic.ini +0 -34
  7. phoenix/db/engines.py +27 -10
  8. phoenix/db/enums.py +20 -0
  9. phoenix/db/facilitator.py +112 -0
  10. phoenix/db/insertion/dataset.py +0 -1
  11. phoenix/db/insertion/types.py +1 -1
  12. phoenix/db/migrate.py +3 -3
  13. phoenix/db/migrations/env.py +0 -7
  14. phoenix/db/migrations/versions/cd164e83824f_users_and_tokens.py +157 -0
  15. phoenix/db/models.py +145 -60
  16. phoenix/experiments/evaluators/code_evaluators.py +9 -3
  17. phoenix/experiments/functions.py +1 -4
  18. phoenix/inferences/fixtures.py +0 -1
  19. phoenix/inferences/inferences.py +0 -1
  20. phoenix/logging/__init__.py +3 -0
  21. phoenix/logging/_config.py +90 -0
  22. phoenix/logging/_filter.py +6 -0
  23. phoenix/logging/_formatter.py +69 -0
  24. phoenix/metrics/__init__.py +0 -1
  25. phoenix/otel/settings.py +4 -4
  26. phoenix/server/api/README.md +28 -0
  27. phoenix/server/api/auth.py +32 -0
  28. phoenix/server/api/context.py +50 -2
  29. phoenix/server/api/dataloaders/__init__.py +4 -0
  30. phoenix/server/api/dataloaders/user_roles.py +30 -0
  31. phoenix/server/api/dataloaders/users.py +33 -0
  32. phoenix/server/api/exceptions.py +7 -0
  33. phoenix/server/api/mutations/__init__.py +0 -2
  34. phoenix/server/api/mutations/api_key_mutations.py +104 -86
  35. phoenix/server/api/mutations/dataset_mutations.py +8 -8
  36. phoenix/server/api/mutations/experiment_mutations.py +2 -2
  37. phoenix/server/api/mutations/export_events_mutations.py +3 -3
  38. phoenix/server/api/mutations/project_mutations.py +3 -3
  39. phoenix/server/api/mutations/span_annotations_mutations.py +4 -4
  40. phoenix/server/api/mutations/trace_annotations_mutations.py +4 -4
  41. phoenix/server/api/mutations/user_mutations.py +282 -42
  42. phoenix/server/api/openapi/schema.py +2 -2
  43. phoenix/server/api/queries.py +48 -39
  44. phoenix/server/api/routers/__init__.py +11 -0
  45. phoenix/server/api/routers/auth.py +284 -0
  46. phoenix/server/api/routers/embeddings.py +26 -0
  47. phoenix/server/api/routers/oauth2.py +456 -0
  48. phoenix/server/api/routers/v1/__init__.py +38 -16
  49. phoenix/server/api/routers/v1/datasets.py +0 -1
  50. phoenix/server/api/types/ApiKey.py +11 -0
  51. phoenix/server/api/types/AuthMethod.py +9 -0
  52. phoenix/server/api/types/User.py +48 -4
  53. phoenix/server/api/types/UserApiKey.py +35 -1
  54. phoenix/server/api/types/UserRole.py +7 -0
  55. phoenix/server/app.py +105 -34
  56. phoenix/server/bearer_auth.py +161 -0
  57. phoenix/server/email/__init__.py +0 -0
  58. phoenix/server/email/sender.py +26 -0
  59. phoenix/server/email/templates/__init__.py +0 -0
  60. phoenix/server/email/templates/password_reset.html +19 -0
  61. phoenix/server/email/types.py +11 -0
  62. phoenix/server/grpc_server.py +6 -0
  63. phoenix/server/jwt_store.py +504 -0
  64. phoenix/server/main.py +61 -30
  65. phoenix/server/oauth2.py +51 -0
  66. phoenix/server/prometheus.py +20 -0
  67. phoenix/server/rate_limiters.py +191 -0
  68. phoenix/server/static/.vite/manifest.json +31 -31
  69. phoenix/server/static/assets/{components-Dte7_KRd.js → components-REunxTt6.js} +348 -286
  70. phoenix/server/static/assets/index-DAPJxlCw.js +101 -0
  71. phoenix/server/static/assets/{pages-CnTvEGEN.js → pages-1VrMk2pW.js} +559 -291
  72. phoenix/server/static/assets/{vendor-BC3OPQuM.js → vendor-B5IC0ivG.js} +5 -5
  73. phoenix/server/static/assets/{vendor-arizeai-NjB3cZzD.js → vendor-arizeai-aFbT4kl1.js} +2 -2
  74. phoenix/server/static/assets/{vendor-codemirror-gE_JCOgX.js → vendor-codemirror-BEGorXSV.js} +1 -1
  75. phoenix/server/static/assets/{vendor-recharts-BXLYwcXF.js → vendor-recharts-6nUU7gU_.js} +1 -1
  76. phoenix/server/telemetry.py +2 -2
  77. phoenix/server/templates/index.html +1 -0
  78. phoenix/server/types.py +157 -1
  79. phoenix/services.py +0 -1
  80. phoenix/session/client.py +7 -3
  81. phoenix/session/evaluation.py +0 -1
  82. phoenix/session/session.py +0 -1
  83. phoenix/settings.py +9 -0
  84. phoenix/trace/exporter.py +0 -1
  85. phoenix/trace/fixtures.py +0 -2
  86. phoenix/utilities/client.py +16 -0
  87. phoenix/utilities/logging.py +9 -1
  88. phoenix/utilities/re.py +3 -3
  89. phoenix/version.py +1 -1
  90. phoenix/db/migrations/future_versions/README.md +0 -4
  91. phoenix/db/migrations/future_versions/cd164e83824f_users_and_tokens.py +0 -293
  92. phoenix/db/migrations/versions/.gitignore +0 -1
  93. phoenix/server/api/mutations/auth.py +0 -18
  94. phoenix/server/api/mutations/auth_mutations.py +0 -65
  95. phoenix/server/static/assets/index-fq1-hCK4.js +0 -100
  96. phoenix/trace/langchain/__init__.py +0 -3
  97. phoenix/trace/langchain/instrumentor.py +0 -35
  98. phoenix/trace/llama_index/__init__.py +0 -3
  99. phoenix/trace/llama_index/callback.py +0 -103
  100. phoenix/trace/openai/__init__.py +0 -3
  101. phoenix/trace/openai/instrumentor.py +0 -31
  102. {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/WHEEL +0 -0
  103. {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/licenses/IP_NOTICE +0 -0
  104. {arize_phoenix-4.35.2.dist-info → arize_phoenix-5.0.0.dist-info}/licenses/LICENSE +0 -0
phoenix/config.py CHANGED
@@ -1,13 +1,19 @@
1
+ import logging
1
2
  import os
2
3
  import re
3
4
  import tempfile
4
- from logging import getLogger
5
+ from dataclasses import dataclass
6
+ from datetime import timedelta
7
+ from enum import Enum
5
8
  from pathlib import Path
6
- from typing import Dict, List, Optional, Tuple
9
+ from typing import Dict, List, Optional, Tuple, overload
10
+ from urllib.parse import urlparse
11
+
12
+ from phoenix.utilities.logging import log_a_list
7
13
 
8
14
  from .utilities.re import parse_env_headers
9
15
 
10
- logger = getLogger(__name__)
16
+ logger = logging.getLogger(__name__)
11
17
 
12
18
  # Phoenix environment variables
13
19
  ENV_PHOENIX_PORT = "PHOENIX_PORT"
@@ -56,6 +62,24 @@ ENV_PHOENIX_ENABLE_PROMETHEUS = "PHOENIX_ENABLE_PROMETHEUS"
56
62
  """
57
63
  Whether to enable Prometheus. Defaults to false.
58
64
  """
65
+ ENV_LOGGING_MODE = "PHOENIX_LOGGING_MODE"
66
+ """
67
+ The logging mode (either 'default' or 'structured').
68
+ """
69
+ ENV_LOGGING_LEVEL = "PHOENIX_LOGGING_LEVEL"
70
+ """
71
+ The logging level ('debug', 'info', 'warning', 'error', 'critical') for the Phoenix backend. For
72
+ database logging see ENV_DB_LOGGING_LEVEL. Defaults to info.
73
+ """
74
+ ENV_DB_LOGGING_LEVEL = "PHOENIX_DB_LOGGING_LEVEL"
75
+ """
76
+ The logging level ('debug', 'info', 'warning', 'error', 'critical') for the Phoenix ORM.
77
+ Defaults to warning.
78
+ """
79
+ ENV_LOG_MIGRATIONS = "PHOENIX_LOG_MIGRATIONS"
80
+ """
81
+ Whether or not to log migrations. Defaults to true.
82
+ """
59
83
 
60
84
  # Phoenix server OpenTelemetry instrumentation environment variables
61
85
  ENV_PHOENIX_SERVER_INSTRUMENTATION_OTLP_TRACE_COLLECTOR_HTTP_ENDPOINT = (
@@ -65,10 +89,50 @@ ENV_PHOENIX_SERVER_INSTRUMENTATION_OTLP_TRACE_COLLECTOR_GRPC_ENDPOINT = (
65
89
  "PHOENIX_SERVER_INSTRUMENTATION_OTLP_TRACE_COLLECTOR_GRPC_ENDPOINT"
66
90
  )
67
91
 
68
- # Auth is under active development. Phoenix users are strongly advised not to
69
- # set these environment variables until the feature is officially released.
70
- ENV_DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH = "DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH"
71
- ENV_DANGEROUSLY_SET_PHOENIX_SECRET = "DANGEROUSLY_SET_PHOENIX_SECRET"
92
+ # Authentication settings
93
+ ENV_PHOENIX_ENABLE_AUTH = "PHOENIX_ENABLE_AUTH"
94
+ ENV_PHOENIX_DISABLE_RATE_LIMIT = "PHOENIX_DISABLE_RATE_LIMIT"
95
+ ENV_PHOENIX_SECRET = "PHOENIX_SECRET"
96
+ ENV_PHOENIX_API_KEY = "PHOENIX_API_KEY"
97
+ ENV_PHOENIX_USE_SECURE_COOKIES = "PHOENIX_USE_SECURE_COOKIES"
98
+ ENV_PHOENIX_ACCESS_TOKEN_EXPIRY_MINUTES = "PHOENIX_ACCESS_TOKEN_EXPIRY_MINUTES"
99
+ """
100
+ The duration, in minutes, before access tokens expire.
101
+ """
102
+ ENV_PHOENIX_REFRESH_TOKEN_EXPIRY_MINUTES = "PHOENIX_REFRESH_TOKEN_EXPIRY_MINUTES"
103
+ """
104
+ The duration, in minutes, before refresh tokens expire.
105
+ """
106
+ ENV_PHOENIX_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES = "PHOENIX_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES"
107
+ """
108
+ The duration, in minutes, before password reset tokens expire.
109
+ """
110
+
111
+ # SMTP settings
112
+ ENV_PHOENIX_SMTP_HOSTNAME = "PHOENIX_SMTP_HOSTNAME"
113
+ """
114
+ The SMTP server hostname to use for sending emails. SMTP is disabled if this is not set.
115
+ """
116
+ ENV_PHOENIX_SMTP_PORT = "PHOENIX_SMTP_PORT"
117
+ """
118
+ The SMTP server port to use for sending emails. Defaults to 587.
119
+ """
120
+ ENV_PHOENIX_SMTP_USERNAME = "PHOENIX_SMTP_USERNAME"
121
+ """
122
+ The SMTP server username to use for sending emails. Should be set if SMTP is enabled.
123
+ """
124
+ ENV_PHOENIX_SMTP_PASSWORD = "PHOENIX_SMTP_PASSWORD"
125
+ """
126
+ The SMTP server password to use for sending emails. Should be set if SMTP is enabled.
127
+ """
128
+ ENV_PHOENIX_SMTP_MAIL_FROM = "PHOENIX_SMTP_MAIL_FROM"
129
+ """
130
+ The email address to use as the sender when sending emails. Should be set if SMTP is enabled.
131
+ """
132
+ ENV_PHOENIX_SMTP_VALIDATE_CERTS = "PHOENIX_SMTP_VALIDATE_CERTS"
133
+ """
134
+ Whether to validate SMTP server certificates. Defaults to true.
135
+ """
72
136
 
73
137
 
74
138
  def server_instrumentation_is_enabled() -> bool:
@@ -110,12 +174,16 @@ def get_working_dir() -> Path:
110
174
  return Path.home().resolve() / ".phoenix"
111
175
 
112
176
 
113
- def get_boolean_env_var(env_var: str) -> Optional[bool]:
177
+ @overload
178
+ def _bool_val(env_var: str) -> Optional[bool]: ...
179
+ @overload
180
+ def _bool_val(env_var: str, default: bool) -> bool: ...
181
+ def _bool_val(env_var: str, default: Optional[bool] = None) -> Optional[bool]:
114
182
  """
115
- Parses a boolean environment variable, returning None if the variable is not set.
183
+ Parses a boolean environment variable, returning `default` if the variable is not set.
116
184
  """
117
185
  if (value := os.environ.get(env_var)) is None:
118
- return None
186
+ return default
119
187
  assert (lower := value.lower()) in (
120
188
  "true",
121
189
  "false",
@@ -123,44 +191,234 @@ def get_boolean_env_var(env_var: str) -> Optional[bool]:
123
191
  return lower == "true"
124
192
 
125
193
 
194
+ @overload
195
+ def _float_val(env_var: str) -> Optional[float]: ...
196
+ @overload
197
+ def _float_val(env_var: str, default: float) -> float: ...
198
+ def _float_val(env_var: str, default: Optional[float] = None) -> Optional[float]:
199
+ """
200
+ Parses a numeric environment variable, returning `default` if the variable is not set.
201
+ """
202
+ if (value := os.environ.get(env_var)) is None:
203
+ return default
204
+ try:
205
+ return float(value)
206
+ except ValueError:
207
+ raise ValueError(
208
+ f"Invalid value for environment variable {env_var}: {value}. "
209
+ f"Value must be a number."
210
+ )
211
+
212
+
213
+ @overload
214
+ def _int_val(env_var: str) -> Optional[int]: ...
215
+ @overload
216
+ def _int_val(env_var: str, default: int) -> int: ...
217
+ def _int_val(env_var: str, default: Optional[int] = None) -> Optional[int]:
218
+ """
219
+ Parses a numeric environment variable, returning `default` if the variable is not set.
220
+ """
221
+ if (value := os.environ.get(env_var)) is None:
222
+ return default
223
+ try:
224
+ return int(value)
225
+ except ValueError:
226
+ raise ValueError(
227
+ f"Invalid value for environment variable {env_var}: {value}. "
228
+ f"Value must be an integer."
229
+ )
230
+
231
+
126
232
  def get_env_enable_auth() -> bool:
127
233
  """
128
- Gets the value of the DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH environment variable.
234
+ Gets the value of the PHOENIX_ENABLE_AUTH environment variable.
235
+ """
236
+ return _bool_val(ENV_PHOENIX_ENABLE_AUTH, False)
237
+
238
+
239
+ def get_env_disable_rate_limit() -> bool:
240
+ """
241
+ Gets the value of the PHOENIX_DISABLE_RATE_LIMIT environment variable.
129
242
  """
130
- return get_boolean_env_var(ENV_DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH) is True
243
+ return _bool_val(ENV_PHOENIX_DISABLE_RATE_LIMIT, False)
131
244
 
132
245
 
133
246
  def get_env_phoenix_secret() -> Optional[str]:
134
247
  """
135
- Gets the value of the DANGEROUSLY_SET_PHOENIX_SECRET environment variable
248
+ Gets the value of the PHOENIX_SECRET environment variable
136
249
  and performs validation.
137
250
  """
138
- phoenix_secret = os.environ.get(ENV_DANGEROUSLY_SET_PHOENIX_SECRET)
251
+ phoenix_secret = os.environ.get(ENV_PHOENIX_SECRET)
139
252
  if phoenix_secret is None:
140
253
  return None
141
- # todo: add validation for the phoenix secret
254
+ from phoenix.auth import REQUIREMENTS_FOR_PHOENIX_SECRET
255
+
256
+ REQUIREMENTS_FOR_PHOENIX_SECRET.validate(phoenix_secret, "Phoenix secret")
142
257
  return phoenix_secret
143
258
 
144
259
 
145
- def get_auth_settings() -> Tuple[bool, Optional[str]]:
260
+ def get_env_phoenix_use_secure_cookies() -> bool:
261
+ return _bool_val(ENV_PHOENIX_USE_SECURE_COOKIES, False)
262
+
263
+
264
+ def get_env_phoenix_api_key() -> Optional[str]:
265
+ return os.environ.get(ENV_PHOENIX_API_KEY)
266
+
267
+
268
+ def get_env_auth_settings() -> Tuple[bool, Optional[str]]:
146
269
  """
147
270
  Gets auth settings and performs validation.
148
271
  """
149
272
  enable_auth = get_env_enable_auth()
150
273
  phoenix_secret = get_env_phoenix_secret()
151
- if enable_auth:
152
- assert phoenix_secret, (
153
- "DANGEROUSLY_SET_PHOENIX_SECRET must be set "
154
- "when auth is enabled with DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH"
155
- )
156
- else:
157
- assert not phoenix_secret, (
158
- "DANGEROUSLY_SET_PHOENIX_SECRET cannot be set "
159
- "unless auth is enabled with DANGEROUSLY_SET_PHOENIX_ENABLE_AUTH"
274
+ if enable_auth and not phoenix_secret:
275
+ raise ValueError(
276
+ f"`{ENV_PHOENIX_SECRET}` must be set when "
277
+ f"auth is enabled with `{ENV_PHOENIX_ENABLE_AUTH}`"
160
278
  )
161
279
  return enable_auth, phoenix_secret
162
280
 
163
281
 
282
+ def get_env_password_reset_token_expiry() -> timedelta:
283
+ """
284
+ Gets the password reset token expiry.
285
+ """
286
+ from phoenix.auth import DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES
287
+
288
+ minutes = _float_val(
289
+ ENV_PHOENIX_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES,
290
+ DEFAULT_PASSWORD_RESET_TOKEN_EXPIRY_MINUTES,
291
+ )
292
+ assert minutes > 0
293
+ return timedelta(minutes=minutes)
294
+
295
+
296
+ def get_env_access_token_expiry() -> timedelta:
297
+ """
298
+ Gets the access token expiry.
299
+ """
300
+ from phoenix.auth import DEFAULT_ACCESS_TOKEN_EXPIRY_MINUTES
301
+
302
+ minutes = _float_val(
303
+ ENV_PHOENIX_ACCESS_TOKEN_EXPIRY_MINUTES,
304
+ DEFAULT_ACCESS_TOKEN_EXPIRY_MINUTES,
305
+ )
306
+ assert minutes > 0
307
+ return timedelta(minutes=minutes)
308
+
309
+
310
+ def get_env_refresh_token_expiry() -> timedelta:
311
+ """
312
+ Gets the refresh token expiry.
313
+ """
314
+ from phoenix.auth import DEFAULT_REFRESH_TOKEN_EXPIRY_MINUTES
315
+
316
+ minutes = _float_val(
317
+ ENV_PHOENIX_REFRESH_TOKEN_EXPIRY_MINUTES,
318
+ DEFAULT_REFRESH_TOKEN_EXPIRY_MINUTES,
319
+ )
320
+ assert minutes > 0
321
+ return timedelta(minutes=minutes)
322
+
323
+
324
+ def get_env_smtp_username() -> str:
325
+ return os.getenv(ENV_PHOENIX_SMTP_USERNAME) or ""
326
+
327
+
328
+ def get_env_smtp_password() -> str:
329
+ return os.getenv(ENV_PHOENIX_SMTP_PASSWORD) or ""
330
+
331
+
332
+ def get_env_smtp_mail_from() -> str:
333
+ return os.getenv(ENV_PHOENIX_SMTP_MAIL_FROM) or "noreply@arize.com"
334
+
335
+
336
+ def get_env_smtp_hostname() -> str:
337
+ return os.getenv(ENV_PHOENIX_SMTP_HOSTNAME) or ""
338
+
339
+
340
+ def get_env_smtp_port() -> int:
341
+ port = _int_val(ENV_PHOENIX_SMTP_PORT, 587)
342
+ assert 0 < port <= 65_535
343
+ return port
344
+
345
+
346
+ def get_env_smtp_validate_certs() -> bool:
347
+ return _bool_val(ENV_PHOENIX_SMTP_VALIDATE_CERTS, True)
348
+
349
+
350
+ @dataclass(frozen=True)
351
+ class OAuth2ClientConfig:
352
+ idp_name: str
353
+ idp_display_name: str
354
+ client_id: str
355
+ client_secret: str
356
+ oidc_config_url: str
357
+
358
+ @classmethod
359
+ def from_env(cls, idp_name: str) -> "OAuth2ClientConfig":
360
+ idp_name_upper = idp_name.upper()
361
+ if not (
362
+ client_id := os.getenv(
363
+ client_id_env_var := f"PHOENIX_OAUTH2_{idp_name_upper}_CLIENT_ID"
364
+ )
365
+ ):
366
+ raise ValueError(
367
+ f"A client id must be set for the {idp_name} OAuth2 IDP "
368
+ f"via the {client_id_env_var} environment variable"
369
+ )
370
+ if not (
371
+ client_secret := os.getenv(
372
+ client_secret_env_var := f"PHOENIX_OAUTH2_{idp_name_upper}_CLIENT_SECRET"
373
+ )
374
+ ):
375
+ raise ValueError(
376
+ f"A client secret must be set for the {idp_name} OAuth2 IDP "
377
+ f"via the {client_secret_env_var} environment variable"
378
+ )
379
+ if not (
380
+ oidc_config_url := (
381
+ os.getenv(
382
+ oidc_config_url_env_var := f"PHOENIX_OAUTH2_{idp_name_upper}_OIDC_CONFIG_URL",
383
+ )
384
+ )
385
+ ):
386
+ raise ValueError(
387
+ f"An OpenID Connect configuration URL must be set for the {idp_name} OAuth2 IDP "
388
+ f"via the {oidc_config_url_env_var} environment variable"
389
+ )
390
+ if urlparse(oidc_config_url).scheme != "https":
391
+ raise ValueError(
392
+ f"Server metadata URL for {idp_name} OAuth2 IDP "
393
+ "must be a valid URL using the https protocol"
394
+ )
395
+ return cls(
396
+ idp_name=idp_name,
397
+ idp_display_name=os.getenv(
398
+ f"PHOENIX_OAUTH2_{idp_name_upper}_DISPLAY_NAME",
399
+ _get_default_idp_display_name(idp_name),
400
+ ),
401
+ client_id=client_id,
402
+ client_secret=client_secret,
403
+ oidc_config_url=oidc_config_url,
404
+ )
405
+
406
+
407
+ def get_env_oauth2_settings() -> List[OAuth2ClientConfig]:
408
+ """
409
+ Get OAuth2 settings from environment variables.
410
+ """
411
+
412
+ idp_names = set()
413
+ pattern = re.compile(
414
+ r"^PHOENIX_OAUTH2_(\w+)_(DISPLAY_NAME|CLIENT_ID|CLIENT_SECRET|OIDC_CONFIG_URL)$"
415
+ )
416
+ for env_var in os.environ:
417
+ if (match := pattern.match(env_var)) is not None and (idp_name := match.group(1).lower()):
418
+ idp_names.add(idp_name)
419
+ return [OAuth2ClientConfig.from_env(idp_name) for idp_name in sorted(idp_names)]
420
+
421
+
164
422
  PHOENIX_DIR = Path(__file__).resolve().parent
165
423
  # Server config
166
424
  SERVER_DIR = PHOENIX_DIR / "server"
@@ -184,8 +442,6 @@ EXPORT_DIR = ROOT_DIR / "exports"
184
442
  INFERENCES_DIR = ROOT_DIR / "inferences"
185
443
  TRACE_DATASETS_DIR = ROOT_DIR / "trace_datasets"
186
444
 
187
- ENABLE_AUTH, PHOENIX_SECRET = get_auth_settings()
188
-
189
445
 
190
446
  def ensure_working_dir() -> None:
191
447
  """
@@ -331,5 +587,91 @@ def get_web_base_url() -> str:
331
587
  return get_base_url()
332
588
 
333
589
 
590
+ class LoggingMode(Enum):
591
+ DEFAULT = "default"
592
+ STRUCTURED = "structured"
593
+
594
+
595
+ def get_env_logging_mode() -> LoggingMode:
596
+ if (logging_mode := os.getenv(ENV_LOGGING_MODE)) is None:
597
+ return LoggingMode.DEFAULT
598
+ try:
599
+ return LoggingMode(logging_mode.lower().strip())
600
+ except ValueError:
601
+ raise ValueError(
602
+ f"Invalid value `{logging_mode}` for env var `{ENV_LOGGING_MODE}`. "
603
+ f"Valid values are: {log_a_list([mode.value for mode in LoggingMode],'and')} "
604
+ "(case-insensitive)."
605
+ )
606
+
607
+
608
+ def get_env_logging_level() -> int:
609
+ return _get_logging_level(
610
+ env_var=ENV_LOGGING_LEVEL,
611
+ default_level=logging.INFO,
612
+ )
613
+
614
+
615
+ def get_env_db_logging_level() -> int:
616
+ return _get_logging_level(
617
+ env_var=ENV_DB_LOGGING_LEVEL,
618
+ default_level=logging.WARNING,
619
+ )
620
+
621
+
622
+ def _get_logging_level(env_var: str, default_level: int) -> int:
623
+ logging_level = os.getenv(env_var)
624
+ if not logging_level:
625
+ return default_level
626
+
627
+ # levelNamesMapping = logging.getLevelNamesMapping() is not supported in python 3.8
628
+ # but is supported in 3.12. Hence, we define the mapping ourselves and will remove
629
+ # this once we drop support for older python versions
630
+ levelNamesMapping = logging._nameToLevel.copy()
631
+
632
+ valid_values = [level for level in levelNamesMapping if level != "NOTSET"]
633
+
634
+ if logging_level.upper() not in valid_values:
635
+ raise ValueError(
636
+ f"Invalid value `{logging_level}` for env var `{env_var}`. "
637
+ f"Valid values are: {log_a_list(valid_values,'and')} (case-insensitive)."
638
+ )
639
+ return levelNamesMapping[logging_level.upper()]
640
+
641
+
642
+ def get_env_log_migrations() -> bool:
643
+ log_migrations = os.getenv(ENV_LOG_MIGRATIONS)
644
+ # Default to True
645
+ if log_migrations is None:
646
+ return True
647
+
648
+ if log_migrations.lower() == "true":
649
+ return True
650
+ elif log_migrations.lower() == "false":
651
+ return False
652
+ else:
653
+ raise ValueError(
654
+ f"Invalid value for environment variable {ENV_LOG_MIGRATIONS}: "
655
+ f"{log_migrations}. Value values are 'TRUE' and 'FALSE' (case-insensitive)."
656
+ )
657
+
658
+
659
+ class OAuth2Idp(Enum):
660
+ AWS_COGNITO = "aws_cognito"
661
+ GOOGLE = "google"
662
+ MICROSOFT_ENTRA_ID = "microsoft_entra_id"
663
+
664
+
665
+ def _get_default_idp_display_name(idp_name: str) -> str:
666
+ """
667
+ Get the default display name for an OAuth2 IDP.
668
+ """
669
+ if idp_name == OAuth2Idp.AWS_COGNITO.value:
670
+ return "AWS Cognito"
671
+ if idp_name == OAuth2Idp.MICROSOFT_ENTRA_ID.value:
672
+ return "Microsoft Entra ID"
673
+ return idp_name.replace("_", " ").title()
674
+
675
+
334
676
  DEFAULT_PROJECT_NAME = "default"
335
677
  _KUBERNETES_PHOENIX_PORT_PATTERN = re.compile(r"^tcp://\d{1,3}[.]\d{1,3}[.]\d{1,3}[.]\d{1,3}:\d+$")
phoenix/db/alembic.ini CHANGED
@@ -83,37 +83,3 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne
83
83
  # ruff.executable = %(here)s/.venv/bin/ruff
84
84
  # ruff.options = --fix REVISION_SCRIPT_FILENAME
85
85
 
86
- # Logging configuration
87
- [loggers]
88
- keys = root,sqlalchemy,alembic
89
-
90
- [handlers]
91
- keys = console
92
-
93
- [formatters]
94
- keys = generic
95
-
96
- [logger_root]
97
- level = WARN
98
- handlers = console
99
- qualname =
100
-
101
- [logger_sqlalchemy]
102
- level = WARN
103
- handlers =
104
- qualname = sqlalchemy.engine
105
-
106
- [logger_alembic]
107
- level = WARN
108
- handlers =
109
- qualname = alembic
110
-
111
- [handler_console]
112
- class = StreamHandler
113
- args = (sys.stderr,)
114
- level = NOTSET
115
- formatter = generic
116
-
117
- [formatter_generic]
118
- format = %(levelname)-5.5s [%(name)s] %(message)s
119
- datefmt = %H:%M:%S
phoenix/db/engines.py CHANGED
@@ -13,7 +13,7 @@ from sqlalchemy import URL, StaticPool, event, make_url
13
13
  from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
14
14
  from typing_extensions import assert_never
15
15
 
16
- from phoenix.config import get_env_database_schema
16
+ from phoenix.config import LoggingMode, get_env_database_schema
17
17
  from phoenix.db.helpers import SupportedSQLDialect
18
18
  from phoenix.db.migrate import migrate_in_thread
19
19
  from phoenix.db.models import init_models
@@ -64,7 +64,7 @@ def get_async_db_url(connection_str: str) -> URL:
64
64
  def create_engine(
65
65
  connection_str: str,
66
66
  migrate: bool = True,
67
- echo: bool = False,
67
+ log_to_stdout: bool = False,
68
68
  ) -> AsyncEngine:
69
69
  """
70
70
  Factory to create a SQLAlchemy engine from a URL string.
@@ -74,10 +74,25 @@ def create_engine(
74
74
  raise ValueError("Failed to parse database from connection string")
75
75
  backend = SupportedSQLDialect(url.get_backend_name())
76
76
  url = get_async_db_url(url.render_as_string(hide_password=False))
77
+ # If Phoenix is run as an application, we want to pass log_migrations_to_stdout=False
78
+ # and let the configured sqlalchemy logger handle the migration logs
79
+ log_migrations_to_stdout = (
80
+ Settings.log_migrations and Settings.logging_mode != LoggingMode.STRUCTURED
81
+ )
77
82
  if backend is SupportedSQLDialect.SQLITE:
78
- return aio_sqlite_engine(url=url, migrate=migrate, echo=echo)
83
+ return aio_sqlite_engine(
84
+ url=url,
85
+ migrate=migrate,
86
+ log_to_stdout=log_to_stdout,
87
+ log_migrations_to_stdout=log_migrations_to_stdout,
88
+ )
79
89
  elif backend is SupportedSQLDialect.POSTGRESQL:
80
- return aio_postgresql_engine(url=url, migrate=migrate, echo=echo)
90
+ return aio_postgresql_engine(
91
+ url=url,
92
+ migrate=migrate,
93
+ log_to_stdout=log_to_stdout,
94
+ log_migrations_to_stdout=log_migrations_to_stdout,
95
+ )
81
96
  else:
82
97
  assert_never(backend)
83
98
 
@@ -85,8 +100,9 @@ def create_engine(
85
100
  def aio_sqlite_engine(
86
101
  url: URL,
87
102
  migrate: bool = True,
88
- echo: bool = False,
89
103
  shared_cache: bool = True,
104
+ log_to_stdout: bool = False,
105
+ log_migrations_to_stdout: bool = True,
90
106
  ) -> AsyncEngine:
91
107
  database = url.database or ":memory:"
92
108
  if database.startswith("file:"):
@@ -105,7 +121,7 @@ def aio_sqlite_engine(
105
121
 
106
122
  engine = create_async_engine(
107
123
  url=url,
108
- echo=echo,
124
+ echo=log_to_stdout,
109
125
  json_serializer=_dumps,
110
126
  async_creator=async_creator,
111
127
  poolclass=StaticPool,
@@ -123,7 +139,7 @@ def aio_sqlite_engine(
123
139
  else:
124
140
  sync_engine = sqlalchemy.create_engine(
125
141
  url=url.set(drivername="sqlite"),
126
- echo=Settings.log_migrations,
142
+ echo=log_migrations_to_stdout,
127
143
  json_serializer=_dumps,
128
144
  creator=lambda: sqlean.connect(f"file:{database}", uri=True),
129
145
  )
@@ -143,14 +159,15 @@ def set_postgresql_search_path(schema: str) -> Callable[[Connection, Any], None]
143
159
  def aio_postgresql_engine(
144
160
  url: URL,
145
161
  migrate: bool = True,
146
- echo: bool = False,
162
+ log_to_stdout: bool = False,
163
+ log_migrations_to_stdout: bool = True,
147
164
  ) -> AsyncEngine:
148
- engine = create_async_engine(url=url, echo=echo, json_serializer=_dumps)
165
+ engine = create_async_engine(url=url, echo=log_to_stdout, json_serializer=_dumps)
149
166
  if not migrate:
150
167
  return engine
151
168
  sync_engine = sqlalchemy.create_engine(
152
169
  url=url.set(drivername="postgresql+psycopg"),
153
- echo=Settings.log_migrations,
170
+ echo=log_migrations_to_stdout,
154
171
  json_serializer=_dumps,
155
172
  )
156
173
  if schema := get_env_database_schema():
phoenix/db/enums.py ADDED
@@ -0,0 +1,20 @@
1
+ from enum import Enum
2
+ from typing import Mapping, Type
3
+
4
+ from sqlalchemy.orm import InstrumentedAttribute
5
+
6
+ from phoenix.db import models
7
+ from phoenix.db.models import AuthMethod
8
+
9
+ __all__ = ["AuthMethod", "UserRole", "COLUMN_ENUMS"]
10
+
11
+
12
+ class UserRole(Enum):
13
+ SYSTEM = "SYSTEM"
14
+ ADMIN = "ADMIN"
15
+ MEMBER = "MEMBER"
16
+
17
+
18
+ COLUMN_ENUMS: Mapping[InstrumentedAttribute[str], Type[Enum]] = {
19
+ models.UserRole.name: UserRole,
20
+ }