88
99from enum import Enum
1010from humps import decamelize
11- from typing import Optional , List , Dict , Callable
11+ from typing import Dict , List , Optional , Callable
1212from dataclasses import dataclass , field , asdict
1313from datetime import datetime
1414from requests_toolbelt .sessions import BaseUrlSession
2020 QueryOptions ,
2121 QueryLegacyResult ,
2222 QueryKind ,
23+ MplResult ,
2324)
2425from .annotations import AnnotationsClient
2526from .users import UsersClient
2627from .version import __version__
27- from .util import from_dict , handle_json_serialization , is_personal_token
28+ from .util import (
29+ from_dict ,
30+ format_datetime_rfc3339_utc ,
31+ handle_json_serialization ,
32+ is_personal_token ,
33+ )
2834from .tokens import TokensClient
2935
30-
3136AXIOM_URL = "https://api.axiom.co"
3237
3338
@@ -41,6 +46,12 @@ def __init__(self):
4146 )
4247
4348
49+ class EdgeResolutionError (Exception ):
50+ """Raised when the edge domain for a dataset cannot be resolved."""
51+
52+ pass
53+
54+
4455@dataclass
4556class IngestFailure :
4657 """The ingestion failure of a single event"""
@@ -124,6 +135,21 @@ class AplOptions:
124135 limit : Optional [int ] = field (default = None )
125136
126137
138+ @dataclass
139+ class MplOptions :
140+ """Options for an MPL (Metrics Processing Language) query."""
141+
142+ # Start time for the query range (required by the API).
143+ start_time : datetime
144+ # End time for the query range (required by the API).
145+ end_time : datetime
146+ # Additional parameters forwarded to MetricsDB (e.g. filterbar bindings).
147+ params : Optional [Dict [str , str ]] = field (default = None )
148+ # Optional metrics query options passed through to the API payload.
149+ query_options : Optional [Dict [str , str ]] = field (default = None )
150+ nocache : bool = field (default = False )
151+
152+
127153class AxiomError (Exception ):
128154 """This exception is raised on request errors."""
129155
@@ -187,13 +213,13 @@ def __init__(
187213 "https://eu-central-1.aws.edge.axiom.co"). When set, ingest
188214 requests use `/v1/ingest/{dataset}` and query requests use
189215 `/v1/query/_apl`.
190- Must be passed explicitly (not read from environment) .
216+ Falls back to AXIOM_EDGE_URL env var .
191217 Takes precedence over `edge`.
192218 edge: Edge domain for ingest/query operations (e.g.,
193219 "eu-central-1.aws.edge.axiom.co"). When set, ingest
194220 requests use `https://{edge}/v1/ingest/{dataset}` and query
195221 requests use `https://{edge}/v1/query/_apl`.
196- Must be passed explicitly (not read from environment) .
222+ Falls back to AXIOM_EDGE env var .
197223 """
198224 # fallback to env variables if not provided
199225 if token is None :
@@ -202,11 +228,10 @@ def __init__(
202228 org_id = os .getenv ("AXIOM_ORG_ID" )
203229 if url is None :
204230 url = os .getenv ("AXIOM_URL" )
205- # Note: edge_url and edge are NOT auto-read from environment.
206- # Edge configuration must be explicit to avoid accidentally routing
207- # all requests through edge when AXIOM_EDGE_URL or AXIOM_EDGE is set for
208- # edge-specific tests. Create a separate Client with edge_url
209- # for edge operations.
231+ if edge_url is None :
232+ edge_url = os .getenv ("AXIOM_EDGE_URL" )
233+ if edge is None :
234+ edge = os .getenv ("AXIOM_EDGE" )
210235
211236 # Normalize empty strings to None for edge config
212237 edge_url = edge_url or None
@@ -269,49 +294,49 @@ def is_edge_configured(self) -> bool:
269294 """Check if edge is configured."""
270295 return self ._edge_url is not None or self ._edge is not None
271296
272- def _get_edge_ingest_url (self , dataset : str ) -> Optional [str ]:
297+ def _resolve_edge_url (self , path_suffix : str ) -> Optional [str ]:
273298 """
274- Get the full edge ingest URL for a dataset.
275- Returns None if edge is not configured.
299+ Resolve an edge URL for the given path suffix.
300+
301+ If edge_url includes a custom path, it is used as-is.
302+ If edge_url is just a host/base URL, the suffix is appended.
303+ If only edge domain is configured, https://{edge}/{suffix} is used.
276304 """
277305 if self ._edge_url is not None :
278306 url = self ._edge_url .rstrip ("/" )
279307 parsed = urlparse (url )
280308 path = parsed .path
281309
282- # If path is empty or just "/", append edge ingest format
283310 if path == "" or path == "/" :
284- return f"{ parsed .scheme } ://{ parsed .netloc } /v1/ingest/ { dataset } "
311+ return f"{ parsed .scheme } ://{ parsed .netloc } /{ path_suffix } "
285312
286- # edge_url has a custom path, use as-is
287313 return url
288314
289315 if self ._edge is not None :
290- return f"https://{ self ._edge .rstrip ('/' )} /v1/ingest/ { dataset } "
316+ return f"https://{ self ._edge .rstrip ('/' )} /{ path_suffix } "
291317
292318 return None
293319
320+ def _get_edge_ingest_url (self , dataset : str ) -> Optional [str ]:
321+ """
322+ Get the full edge ingest URL for a dataset.
323+ Returns None if edge is not configured.
324+ """
325+ return self ._resolve_edge_url (f"v1/ingest/{ dataset } " )
326+
294327 def _get_edge_query_url (self ) -> Optional [str ]:
295328 """
296329 Get the full edge query URL.
297330 Returns None if edge is not configured.
298331 """
299- if self ._edge_url is not None :
300- url = self ._edge_url .rstrip ("/" )
301- parsed = urlparse (url )
302- path = parsed .path
303-
304- # If path is empty or just "/", append edge query format
305- if path == "" or path == "/" :
306- return f"{ parsed .scheme } ://{ parsed .netloc } /v1/query/_apl"
332+ return self ._resolve_edge_url ("v1/query/_apl" )
307333
308- # edge_url has a custom path, use as-is
309- return url
310-
311- if self ._edge is not None :
312- return f"https://{ self ._edge .rstrip ('/' )} /v1/query/_apl"
313-
314- return None
334+ def _get_edge_mpl_url (self ) -> Optional [str ]:
335+ """
336+ Get the full edge MPL query URL.
337+ Returns None if edge is not configured.
338+ """
339+ return self ._resolve_edge_url ("v1/query/_mpl" )
315340
316341 def before_shutdown (self , func : Callable ):
317342 self .before_shutdown_funcs .append (func )
@@ -452,6 +477,61 @@ def query(
452477
453478 return result
454479
480+ def mpl_query (self , mpl : str , opts : MplOptions ) -> MplResult :
481+ """
482+ Execute an MPL (Metrics Processing Language) query.
483+
484+ MPL queries are edge-only. Configure the client with edge_url or
485+ edge to specify the endpoint. Edge endpoints require API tokens
486+ (xaat-), not personal tokens.
487+
488+ Args:
489+ mpl: MPL query string (e.g.
490+ "`my-dataset`:`http.requests` | align to 5m using avg")
491+ opts: Query options including required start_time and end_time.
492+
493+ Returns:
494+ MplResult containing the time-series data.
495+ """
496+ url = self ._get_edge_mpl_url ()
497+ if url is None :
498+ raise EdgeResolutionError (
499+ "MPL queries require an edge endpoint; "
500+ "set edge_url or edge when creating the Client"
501+ )
502+ if is_personal_token (self ._token ):
503+ raise PersonalTokenNotSupportedForEdgeError ()
504+
505+ payload = ujson .dumps (
506+ self ._prepare_mpl_payload (mpl , opts ),
507+ default = handle_json_serialization ,
508+ )
509+ params = self ._prepare_mpl_params (opts )
510+ res = self .session .post (url , data = payload , params = params )
511+ result = from_dict (MplResult , res .json ())
512+ result .savedQueryID = res .headers .get ("X-Axiom-History-Query-Id" )
513+ return result
514+
515+ def _prepare_mpl_payload (
516+ self , mpl : str , opts : MplOptions
517+ ) -> Dict [str , object ]:
518+ payload : Dict [str , object ] = {
519+ "mpl" : mpl ,
520+ "startTime" : format_datetime_rfc3339_utc (opts .start_time ),
521+ "endTime" : format_datetime_rfc3339_utc (opts .end_time ),
522+ }
523+ if opts .params :
524+ payload ["params" ] = opts .params
525+ if opts .query_options :
526+ payload ["queryOptions" ] = opts .query_options
527+ return payload
528+
529+ def _prepare_mpl_params (self , opts : MplOptions ) -> Dict [str , object ]:
530+ params : Dict [str , object ] = {"format" : "metrics-v2" }
531+ if opts .nocache :
532+ params ["nocache" ] = "true"
533+ return params
534+
455535 def _prepare_query_options (self , opts : QueryOptions ) -> Dict [str , object ]:
456536 """returns the query options as a Dict, handles any renaming for key
457537 fields."""
0 commit comments