WayfinderAK 6f7549d439
All checks were successful
build-image / docker (push) Successful in 53s
Implement phase 1 foundations
2026-07-03 20:14:06 -08:00

149 lines
3.7 KiB
Go

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"`
}