Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Is

A Cycloid plugin that ETLs data from Sentry (organizations → projects → issues) into a local SQLite database and exposes HTTP endpoints for the Cycloid platform to interact with.

## Commands

```bash
# Build
go build -o sentry main.go

# Run tests (requires Docker — tests run inside container)
make test
make test ARGS="./service" # specific package

# Regenerate mocks and enum string methods
make gen
```

## Environment Variables

| Variable | Default | Required |
|----------|---------|----------|
| `SENTRY_API_KEY` | — | Yes |
| `SENTRY_ENDPOINT` | `http://sentry.io/api/0/` | No |
| `SENTRY_ORGANIZATION_SLUG` | — | No (fetches all orgs if unset) |
| `DB_FILE` | — | No (in-memory SQLite if unset) |
| `PORT` | `8080` | No |

## Architecture

### Data Flow

`main.go` initializes DB → applies `schema.sql` (via `//go:embed`) → creates `service.Plugin` → starts HTTP server + background goroutine that calls `Resync()` immediately.

`Resync()` in `service/service.go`:
1. Deletes all organizations (CASCADE deletes projects and issues via FK constraints)
2. Fetches orgs from Sentry API (or one specific org if slug configured)
3. For each org: fetches projects; for each project: fetches issues
4. Writes everything via repository interfaces

### HTTP Endpoints (`service/transport/http/handler.go`)

- `GET /_cy/ping` — returns status JSON
- `POST /_cy/resync` — manually triggers `Resync()`
- `POST /_cy/events` — stub
- `DELETE /_cy/plugin` — stub

### Layering

- **`sentry/`** — wraps `atlassian/go-sentry-api` client; contains `ToOrganization()`, `ToProject()`, `ToIssue()` converters
- **`organization/`, `project/`, `issue/`** — domain models + repository interfaces
- **`sqlite/`** — SQLite implementations of those repository interfaces
- **`mock/`** — generated mocks (do not edit manually; regenerate with `make gen`)
- **`service/`** — orchestration, status management, HTTP transport

### Key Patterns

**Repository pattern with DI**: `Plugin` struct receives all repositories and the Sentry client via `New()`. No global state.

**Status enum** (`service/status.go`): `Ok`, `Syncthing`, `Error` — protected by `sync.RWMutex`. Status is set to `Error` on Resync failures but the loop continues to next item.

**Enums with codegen**: `service.Status`, `event.Severity`, `event.Type`, `event.Color` use `github.com/dmarkham/enumer`. Add `//go:generate` directives and run `make gen`.

**NULL helpers in `sqlite/`**: `toNullString()`, `toNullBool()`, `toNullInt64()`, `toNullTime()` convert zero/empty values to SQL NULLs.

### Platform Integration Files

- `manifest.yaml` — plugin metadata, config options, and relation definitions for Cycloid
- `widgets.yaml` — widget queries/selections rendered in the Cycloid UI
- `schema.sql` — embedded in the binary and applied at startup
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ ifeq ($(ARGS),)
endif

VERSION=0.0.1
LOCAL_REGISTRY=localhost:5000

.PHONY: help
help: Makefile ## This help dialog
Expand All @@ -38,3 +39,8 @@ test: ## Tests the Plugin
docker-release: ## Builds the base Docker image for the registry
@docker build -f ./docker/Dockerfile -t cycloid/sentry-plugin:$(VERSION) .
@docker push cycloid/sentry-plugin:$(VERSION)

.PHONY: docker-local
docker-local: ## Builds and pushes the Docker image to a local registry
@docker build --provenance=false -f ./docker/Dockerfile -t $(LOCAL_REGISTRY)/cycloid/sentry-plugin:$(VERSION) .
@docker push $(LOCAL_REGISTRY)/cycloid/sentry-plugin:$(VERSION)
14 changes: 11 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@ func main() {
logger.Error(fmt.Errorf("failed to load config: %w", err).Error())
}

logger.Info("loaded config", "db_file", cfg.DB.File)

// By default we use the 'memory' setting so for testing we can easily use it
q := "file::memory:?cache=shared&_foreign_keys=true"
if cfg.DB.File != "" {
q = cfg.DB.File + "?_foreign_keys=true"
}
logger.Info("opening SQLite database", "dsn", q)
db, err := sql.Open("sqlite3", q)
if err != nil {
started = false
Expand All @@ -67,9 +70,14 @@ func main() {
pr = sqlite.NewProjectRepository(db)
ir = sqlite.NewIssueRepository(db)

ss, err = sentry.New(cfg.Sentry.APIKey, cfg.Sentry.Endpoint)
if err != nil {
started = false
if cfg.Sentry.APIKey == sentry.E2EFixturesKey {
logger.Info("e2e fixture mode enabled: using hardcoded Sentry data")
ss = &sentry.FixtureService{}
} else {
ss, err = sentry.New(cfg.Sentry.APIKey, cfg.Sentry.Endpoint)
if err != nil {
started = false
}
}
}
sctx, cfn := context.WithCancel(context.TODO())
Expand Down
100 changes: 100 additions & 0 deletions sentry/fixtures_e2e.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package sentry

import (
"time"

sentry "github.com/atlassian/go-sentry-api"
)

// E2EFixturesKey is the sentinel value for SENTRY_API_KEY that activates fixture mode.
const E2EFixturesKey = "e2e"

// FixtureService implements Service and returns hardcoded data for e2e testing.
type FixtureService struct{}

func ptr[T any](v T) *T { return &v }

var fixtureOrg = sentry.Organization{
ID: ptr("1"),
Name: "Youdeploy Org",
Slug: ptr("youdeploy-org"),
}

var fixtureProjects = []sentry.Project{
{ID: "101", Name: "YouDeploy API", Slug: ptr("youdeploy-api"), Status: "active"},
{ID: "102", Name: "YouDeploy Frontend", Slug: ptr("youdeploy-frontend"), Status: "active"},
{ID: "103", Name: "YouDeploy Worker", Slug: ptr("youdeploy-worker"), Status: "active"},
}

// fixtureIssueDef holds the static parts of a fixture issue definition.
type fixtureIssueDef struct {
suffix string
title string
level string
hasSeen bool
userCount int
}

// fixtureIssuesByProjectID maps project ID to its specific set of issues.
var fixtureIssuesByProjectID = map[string][]fixtureIssueDef{
"101": {
{"001", "NullPointerException in PaymentService", "error", false, 42},
{"002", "Timeout connecting to database", "warning", true, 7},
{"003", "HTTP 502 Bad Gateway from upstream", "error", false, 13},
},
"102": {
{"001", "Unhandled promise rejection in auth flow", "error", false, 28},
{"002", "React render error in Dashboard component", "error", true, 5},
},
"103": {
{"001", "Memory usage exceeded threshold", "warning", true, 3},
{"002", "Job queue backed up: retries exhausted", "error", false, 19},
{"003", "Deadlock detected in task scheduler", "error", false, 8},
{"004", "Worker failed to connect to Redis", "warning", true, 11},
},
}

func (f *FixtureService) GetOrganizations() ([]sentry.Organization, *sentry.Link, error) {
return []sentry.Organization{fixtureOrg}, nil, nil
}

func (f *FixtureService) GetOrganization(orgSlug string) (sentry.Organization, error) {
org := fixtureOrg
org.Slug = ptr(orgSlug)
return org, nil
}

func (f *FixtureService) GetOrgProjects(o sentry.Organization) ([]sentry.Project, *sentry.Link, error) {
return fixtureProjects, nil, nil
}

func (f *FixtureService) GetIssues(o sentry.Organization, p sentry.Project, statsPeriod *string, shortIDLookup *bool, query *string) ([]sentry.Issue, *sentry.Link, error) {
now := time.Now()
yesterday := now.Add(-24 * time.Hour)
weekAgo := now.Add(-7 * 24 * time.Hour)
issueStatus := sentry.Status("unresolved")

defs := fixtureIssuesByProjectID[p.ID]
issues := make([]sentry.Issue, 0, len(defs))
for _, def := range defs {
// Prefix ID with project ID to keep issues unique across projects.
id := p.ID + "-" + def.suffix
firstSeen := weekAgo
if def.hasSeen {
firstSeen = yesterday
}
issues = append(issues, sentry.Issue{
ID: ptr(id),
Title: ptr("Fixture: " + def.title),
Permalink: ptr("https://sentry.io/organizations/" + *o.Slug + "/issues/" + id + "/"),
HasSeen: ptr(def.hasSeen),
FirstSeen: &firstSeen,
LastSeen: &now,
UserCount: ptr(def.userCount),
Level: ptr(def.level),
Status: &issueStatus,
Type: ptr("error"),
})
}
return issues, nil, nil
}
1 change: 1 addition & 0 deletions service/transport/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func Handler(s service.Service) http.Handler {
r.Handle("POST /_cy/events", eventsHandler(s))
r.Handle("DELETE /_cy/plugin", deletePluginHandler(s))
r.Handle("POST /_cy/resync", resyncHandler(s))
r.Handle("GET /iframe", iframeHandler(s))

return r
}
Expand Down
58 changes: 58 additions & 0 deletions service/transport/http/iframe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package http

import (
"net/http"

"github.com/cycloidio/sentry-plugin/service"
)

const iframeHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body { font-family: sans-serif; font-size: 13px; margin: 16px; color: #222; }
h2 { margin: 0 0 12px; font-size: 15px; }
h3 { margin: 20px 0 6px; font-size: 13px; color: #555; }
table { width: 100%; border-collapse: collapse; margin-bottom: 8px; }
th { background: #f4f4f4; text-align: left; padding: 6px 8px; border-bottom: 2px solid #ddd; }
td { padding: 5px 8px; border-bottom: 1px solid #eee; }
.error { color: #c0392b; }
.warning { color: #e67e22; }
</style>
</head>
<body>
<h2>Sentry Issues (fixture data)</h2>

<h3>YouDeploy API</h3>
<table>
<tr><th>Title</th><th>Level</th><th>Status</th><th>Has Seen</th><th>Users</th></tr>
<tr><td>Fixture: NullPointerException in PaymentService</td><td class="error">error</td><td>unresolved</td><td>false</td><td>42</td></tr>
<tr><td>Fixture: Timeout connecting to database</td><td class="warning">warning</td><td>unresolved</td><td>true</td><td>7</td></tr>
<tr><td>Fixture: HTTP 502 Bad Gateway from upstream</td><td class="error">error</td><td>unresolved</td><td>false</td><td>13</td></tr>
</table>

<h3>YouDeploy Frontend</h3>
<table>
<tr><th>Title</th><th>Level</th><th>Status</th><th>Has Seen</th><th>Users</th></tr>
<tr><td>Fixture: Unhandled promise rejection in auth flow</td><td class="error">error</td><td>unresolved</td><td>false</td><td>28</td></tr>
<tr><td>Fixture: React render error in Dashboard component</td><td class="error">error</td><td>unresolved</td><td>true</td><td>5</td></tr>
</table>

<h3>YouDeploy Worker</h3>
<table>
<tr><th>Title</th><th>Level</th><th>Status</th><th>Has Seen</th><th>Users</th></tr>
<tr><td>Fixture: Memory usage exceeded threshold</td><td class="warning">warning</td><td>unresolved</td><td>true</td><td>3</td></tr>
<tr><td>Fixture: Job queue backed up: retries exhausted</td><td class="error">error</td><td>unresolved</td><td>false</td><td>19</td></tr>
<tr><td>Fixture: Deadlock detected in task scheduler</td><td class="error">error</td><td>unresolved</td><td>false</td><td>8</td></tr>
<tr><td>Fixture: Worker failed to connect to Redis</td><td class="warning">warning</td><td>unresolved</td><td>true</td><td>11</td></tr>
</table>
</body>
</html>`

func iframeHandler(_ service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(iframeHTML))
}
}
8 changes: 8 additions & 0 deletions widgets.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
- type: iframe
placement:
type: component
config:
tab_name: Sentry Preview
widget:
query: "/iframe"

- type: table
placement:
type: component
Expand Down