logs-analyzer/signoz/pkg/query-service/app/integrations/builtin.go

269 lines
6.9 KiB
Go
Raw Normal View History

2024-09-02 22:47:30 +03:00
package integrations
import (
"bytes"
"context"
"embed"
"strings"
"unicode"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"path"
koanfJson "github.com/knadh/koanf/parsers/json"
"go.signoz.io/signoz/pkg/query-service/model"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
)
type BuiltInIntegrations struct{}
var builtInIntegrations map[string]IntegrationDetails
func (bi *BuiltInIntegrations) list(ctx context.Context) (
[]IntegrationDetails, *model.ApiError,
) {
integrations := maps.Values(builtInIntegrations)
slices.SortFunc(integrations, func(i1, i2 IntegrationDetails) int {
return strings.Compare(i1.Id, i2.Id)
})
return integrations, nil
}
func (bi *BuiltInIntegrations) get(
ctx context.Context, integrationIds []string,
) (
map[string]IntegrationDetails, *model.ApiError,
) {
result := map[string]IntegrationDetails{}
for _, iid := range integrationIds {
i, exists := builtInIntegrations[iid]
if exists {
result[iid] = i
}
}
return result, nil
}
//go:embed builtin_integrations/*
var integrationFiles embed.FS
func init() {
err := readBuiltIns()
if err != nil {
panic(fmt.Errorf("couldn't read builtin integrations: %w", err))
}
}
func readBuiltIns() error {
rootDirName := "builtin_integrations"
builtinDirs, err := fs.ReadDir(integrationFiles, rootDirName)
if err != nil {
return fmt.Errorf("couldn't list integrations dirs: %w", err)
}
builtInIntegrations = map[string]IntegrationDetails{}
for _, d := range builtinDirs {
if !d.IsDir() {
continue
}
integrationDir := path.Join(rootDirName, d.Name())
i, err := readBuiltInIntegration(integrationDir)
if err != nil {
return fmt.Errorf("couldn't parse integration %s from files: %w", d.Name(), err)
}
_, exists := builtInIntegrations[i.Id]
if exists {
return fmt.Errorf(
"duplicate integration for id %s at %s", i.Id, d.Name(),
)
}
builtInIntegrations[i.Id] = *i
}
return nil
}
func readBuiltInIntegration(dirpath string) (
*IntegrationDetails, error,
) {
integrationJsonPath := path.Join(dirpath, "integration.json")
serializedSpec, err := integrationFiles.ReadFile(integrationJsonPath)
if err != nil {
return nil, fmt.Errorf("couldn't find integration.json in %s: %w", dirpath, err)
}
integrationSpec, err := koanfJson.Parser().Unmarshal(serializedSpec)
if err != nil {
return nil, fmt.Errorf(
"couldn't parse integration json from %s: %w", integrationJsonPath, err,
)
}
hydrated, err := hydrateFileUris(integrationSpec, dirpath)
if err != nil {
return nil, fmt.Errorf(
"couldn't hydrate files referenced in integration %s: %w", integrationJsonPath, err,
)
}
hydratedSpec := hydrated.(map[string]interface{})
hydratedSpecJson, err := koanfJson.Parser().Marshal(hydratedSpec)
if err != nil {
return nil, fmt.Errorf(
"couldn't serialize hydrated integration spec back to JSON %s: %w", integrationJsonPath, err,
)
}
var integration IntegrationDetails
decoder := json.NewDecoder(bytes.NewReader(hydratedSpecJson))
decoder.DisallowUnknownFields()
err = decoder.Decode(&integration)
if err != nil {
return nil, fmt.Errorf(
"couldn't parse hydrated JSON spec read from %s: %w",
integrationJsonPath, err,
)
}
err = validateIntegration(integration)
if err != nil {
return nil, fmt.Errorf("invalid integration spec %s: %w", integration.Id, err)
}
integration.Id = "builtin-" + integration.Id
if len(integration.DataCollected.Metrics) > 0 {
metricsForConnTest := []string{}
for _, collectedMetric := range integration.DataCollected.Metrics {
promName := toPromMetricName(collectedMetric.Name)
metricsForConnTest = append(metricsForConnTest, promName)
}
integration.ConnectionTests.Metrics = metricsForConnTest
}
return &integration, nil
}
func validateIntegration(i IntegrationDetails) error {
// Validate dashboard data
seenDashboardIds := map[string]interface{}{}
for _, dd := range i.Assets.Dashboards {
did, exists := dd["id"]
if !exists {
return fmt.Errorf("id is required. not specified in dashboard titled %v", dd["title"])
}
dashboardId, ok := did.(string)
if !ok {
return fmt.Errorf("id must be string in dashboard titled %v", dd["title"])
}
if _, seen := seenDashboardIds[dashboardId]; seen {
return fmt.Errorf("multiple dashboards found with id %s", dashboardId)
}
seenDashboardIds[dashboardId] = nil
}
// TODO(Raj): Validate all parts of plugged in integrations
return nil
}
func hydrateFileUris(spec interface{}, basedir string) (interface{}, error) {
if specMap, ok := spec.(map[string]interface{}); ok {
result := map[string]interface{}{}
for k, v := range specMap {
hydrated, err := hydrateFileUris(v, basedir)
if err != nil {
return nil, err
}
result[k] = hydrated
}
return result, nil
} else if specSlice, ok := spec.([]interface{}); ok {
result := []interface{}{}
for _, v := range specSlice {
hydrated, err := hydrateFileUris(v, basedir)
if err != nil {
return nil, err
}
result = append(result, hydrated)
}
return result, nil
} else if maybeFileUri, ok := spec.(string); ok {
return readFileIfUri(maybeFileUri, basedir)
}
return spec, nil
}
func readFileIfUri(maybeFileUri string, basedir string) (interface{}, error) {
fileUriPrefix := "file://"
if !strings.HasPrefix(maybeFileUri, fileUriPrefix) {
return maybeFileUri, nil
}
relativePath := maybeFileUri[len(fileUriPrefix):]
fullPath := path.Join(basedir, relativePath)
fileContents, err := integrationFiles.ReadFile(fullPath)
if err != nil {
return nil, fmt.Errorf("couldn't read referenced file: %w", err)
}
if strings.HasSuffix(maybeFileUri, ".md") {
return string(fileContents), nil
} else if strings.HasSuffix(maybeFileUri, ".json") {
parsed, err := koanfJson.Parser().Unmarshal(fileContents)
if err != nil {
return nil, fmt.Errorf("couldn't parse referenced JSON file: %w", err)
}
return parsed, nil
} else if strings.HasSuffix(maybeFileUri, ".svg") {
base64Svg := base64.StdEncoding.EncodeToString(fileContents)
dataUri := fmt.Sprintf("data:image/svg+xml;base64,%s", base64Svg)
return dataUri, nil
}
return nil, fmt.Errorf("unsupported file type %s", maybeFileUri)
}
// copied from signoz clickhouse exporter's `sanitize` which
// in turn is copied from prometheus-go-metric-exporter
//
// replaces non-alphanumeric characters with underscores in s.
func toPromMetricName(s string) string {
if len(s) == 0 {
return s
}
// Note: No length limit for label keys because Prometheus doesn't
// define a length limit, thus we should NOT be truncating label keys.
// See https://github.com/orijtech/prometheus-go-metrics-exporter/issues/4.
s = strings.Map(func(r rune) rune {
// sanitizeRune converts anything that is not a letter or digit to an underscore
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return r
}
// Everything else turns into an underscore
return '_'
}, s)
if unicode.IsDigit(rune(s[0])) {
s = "key" + "_" + s
}
if s[0] == '_' {
s = "key" + s
}
return s
}