package loki import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time" ) type Client struct { baseURL string tenantID string username string password string http *http.Client } func New(baseURL, tenantID, username, password string) *Client { return &Client{baseURL: strings.TrimRight(baseURL, "/"), tenantID: tenantID, username: username, password: password, http: &http.Client{Timeout: 20 * time.Second}} } type Match struct { Timestamp time.Time `json:"timestamp"` Line string `json:"line"` } type QueryResult struct { Count int `json:"count"` Matches []Match `json:"matches"` } func (c *Client) Ready(ctx context.Context) error { req, err := c.request(ctx, http.MethodGet, "/ready", nil) if err != nil { return err } resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { return fmt.Errorf("loki ready failed: status %d", resp.StatusCode) } return nil } func (c *Client) Labels(ctx context.Context) ([]string, error) { var raw labelsResponse if err := c.getJSON(ctx, "/loki/api/v1/labels", nil, &raw); err != nil { return nil, err } return raw.Data, nil } func (c *Client) LabelValues(ctx context.Context, name string) ([]string, error) { if name == "" || strings.Contains(name, "/") { return nil, fmt.Errorf("invalid label name") } var raw labelsResponse if err := c.getJSON(ctx, "/loki/api/v1/label/"+url.PathEscape(name)+"/values", nil, &raw); err != nil { return nil, err } return raw.Data, nil } func (c *Client) QueryRange(ctx context.Context, logql string, since time.Duration, limit int) (QueryResult, error) { if limit <= 0 { limit = 100 } values := url.Values{} values.Set("query", logql) values.Set("start", strconv.FormatInt(time.Now().Add(-since).UnixNano(), 10)) values.Set("end", strconv.FormatInt(time.Now().UnixNano(), 10)) values.Set("limit", strconv.Itoa(limit)) var raw response if err := c.getJSON(ctx, "/loki/api/v1/query_range", values, &raw); err != nil { return QueryResult{}, err } var result QueryResult for _, stream := range raw.Data.Result { for _, pair := range stream.Values { if len(pair) != 2 { continue } ns, _ := strconv.ParseInt(pair[0], 10, 64) result.Matches = append(result.Matches, Match{Timestamp: time.Unix(0, ns), Line: pair[1]}) } } result.Count = len(result.Matches) return result, nil } func (c *Client) getJSON(ctx context.Context, path string, values url.Values, out any) error { req, err := c.request(ctx, http.MethodGet, path, values) if err != nil { return err } resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { return fmt.Errorf("loki request failed: status %d", resp.StatusCode) } return json.NewDecoder(resp.Body).Decode(out) } func (c *Client) request(ctx context.Context, method, path string, values url.Values) (*http.Request, error) { if c.baseURL == "" { return nil, fmt.Errorf("LOKI_URL is not configured") } target := c.baseURL + path if len(values) > 0 { target += "?" + values.Encode() } req, err := http.NewRequestWithContext(ctx, method, target, nil) if err != nil { return nil, err } if c.tenantID != "" { req.Header.Set("X-Scope-OrgID", c.tenantID) } if c.username != "" || c.password != "" { req.SetBasicAuth(c.username, c.password) } return req, nil } type labelsResponse struct { Status string `json:"status"` Data []string `json:"data"` } type response struct { Status string `json:"status"` Data struct { Result []struct { Stream map[string]string `json:"stream"` Values [][]string `json:"values"` } `json:"result"` } `json:"data"` }