Skip to content

Commit 15929a2

Browse files
committed
feat(frontend): implement Measure AI chat
closes #2725
1 parent a33c279 commit 15929a2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

68 files changed

+16198
-5105
lines changed

backend/api/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ func main() {
133133
teams.GET(":id/members", measure.GetTeamMembers)
134134
teams.DELETE(":id/members/:memberId", measure.RemoveTeamMember)
135135
teams.GET(":id/usage", measure.GetUsage)
136+
teams.POST(":id/usage/ai", measure.ReportAiUsage)
136137
teams.GET(":id/slack", measure.GetTeamSlack)
137138
teams.PATCH(":id/slack/status", measure.UpdateTeamSlackStatus)
138139
}

backend/api/measure/usage.go

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@ import (
1111
"github.com/leporo/sqlf"
1212
)
1313

14+
type AiUsageReport struct {
15+
TeamId string `json:"team_id"`
16+
Model string `json:"model"`
17+
InputTokens uint64 `json:"input_tokens"`
18+
OutputTokens uint64 `json:"output_tokens"`
19+
}
20+
21+
type AiUsage struct {
22+
TeamId string `json:"team_id"`
23+
MonthlyAiUsage []MonthlyAiUsage `json:"monthly_ai_usage"`
24+
}
25+
26+
type MonthlyAiUsage struct {
27+
MonthName string `json:"month_year"`
28+
TotalTokenCount uint64 `json:"total_token_count"`
29+
}
30+
1431
type AppUsage struct {
1532
AppId string `json:"app_id"`
1633
AppName string `json:"app_name"`
@@ -159,6 +176,61 @@ func GetUsage(c *gin.Context) {
159176
return
160177
}
161178

179+
// Query ai metrics for team
180+
aiMetricsStmt := sqlf.
181+
From(`ai_metrics`).
182+
Select("team_id").
183+
Select("formatDateTime(toStartOfMonth(timestamp), '%b %Y') AS month_year").
184+
Select("sumMerge(input_token_count) + sumMerge(output_token_count) AS total_token_count").
185+
Where("timestamp >= addMonths(toStartOfMonth(?), -2) AND timestamp < toStartOfMonth(addMonths(?, 1))", now, now).
186+
GroupBy("team_id, toStartOfMonth(timestamp)").
187+
OrderBy("team_id, toStartOfMonth(timestamp) DESC")
188+
189+
defer aiMetricsStmt.Close()
190+
191+
aiMetricsRows, err := server.Server.ChPool.Query(ctx, aiMetricsStmt.String(), aiMetricsStmt.Args()...)
192+
if err != nil {
193+
msg := fmt.Sprintf("error occurred while querying AI metrics for team: %s", teamId)
194+
fmt.Println(msg, err)
195+
c.JSON(http.StatusInternalServerError, gin.H{"error": msg})
196+
return
197+
}
198+
199+
aiUsageMap := make(map[string]*AiUsage)
200+
201+
// Initialize aiUsageMap
202+
aiUsageMap[teamId.String()] = &AiUsage{
203+
TeamId: teamId.String(),
204+
MonthlyAiUsage: make([]MonthlyAiUsage, 0, 3),
205+
}
206+
207+
// Populate aiUsageMap with metrics rows from DB
208+
for aiMetricsRows.Next() {
209+
var teamId, monthYear string
210+
var totalTokenCount uint64
211+
212+
if err := aiMetricsRows.Scan(&teamId, &monthYear, &totalTokenCount); err != nil {
213+
msg := fmt.Sprintf("error occurred while scanning AI metrics row for team: %s", teamId)
214+
fmt.Println(msg, err)
215+
c.JSON(http.StatusInternalServerError, gin.H{"error": msg})
216+
return
217+
}
218+
219+
if aiUsage, exists := aiUsageMap[teamId]; exists {
220+
aiUsage.MonthlyAiUsage = append(aiUsage.MonthlyAiUsage, MonthlyAiUsage{
221+
MonthName: monthYear,
222+
TotalTokenCount: totalTokenCount,
223+
})
224+
}
225+
}
226+
227+
if err := aiMetricsRows.Err(); err != nil {
228+
msg := fmt.Sprintf("error occurred while iterating AI metrics rows for team: %s", teamId)
229+
fmt.Println(msg, err)
230+
c.JSON(http.StatusInternalServerError, gin.H{"error": msg})
231+
return
232+
}
233+
162234
// Ensure all apps have entries for all three months by adding 0 values for missing months
163235
for _, appUsage := range appUsageMap {
164236
monthDataMap := make(map[string]MonthlyAppUsage)
@@ -183,11 +255,115 @@ func GetUsage(c *gin.Context) {
183255
appUsage.MonthlyAppUsage = newMonthlyAppUsage
184256
}
185257

258+
// Ensure all AI usages have entries for all three months by adding 0 values for missing months
259+
for _, aiUsage := range aiUsageMap {
260+
monthDataMap := make(map[string]MonthlyAiUsage)
261+
for _, usage := range aiUsage.MonthlyAiUsage {
262+
monthDataMap[usage.MonthName] = usage
263+
}
264+
265+
newMonthlyAiUsage := make([]MonthlyAiUsage, 0, 3)
266+
for _, monthName := range monthNames {
267+
if usage, exists := monthDataMap[monthName]; exists {
268+
newMonthlyAiUsage = append(newMonthlyAiUsage, usage)
269+
} else {
270+
newMonthlyAiUsage = append(newMonthlyAiUsage, MonthlyAiUsage{
271+
MonthName: monthName,
272+
TotalTokenCount: 0,
273+
})
274+
}
275+
}
276+
aiUsage.MonthlyAiUsage = newMonthlyAiUsage
277+
}
278+
186279
// Convert map to slice for JSON response
187-
var result []AppUsage
280+
var appUsageResult []AppUsage
188281
for _, appUsage := range appUsageMap {
189-
result = append(result, *appUsage)
282+
appUsageResult = append(appUsageResult, *appUsage)
283+
}
284+
285+
var aiUsageResult []AiUsage
286+
for _, aiUsage := range aiUsageMap {
287+
aiUsageResult = append(aiUsageResult, *aiUsage)
288+
}
289+
290+
result := gin.H{
291+
"app_usage": appUsageResult,
292+
"ai_usage": aiUsageResult,
190293
}
191294

192295
c.JSON(http.StatusOK, result)
193296
}
297+
298+
func ReportAiUsage(c *gin.Context) {
299+
ctx := c.Request.Context()
300+
userId := c.GetString("userId")
301+
teamId, err := uuid.Parse(c.Param("id"))
302+
if err != nil {
303+
msg := `team id invalid or missing`
304+
fmt.Println(msg, err)
305+
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
306+
return
307+
}
308+
309+
var aiUsageReport AiUsageReport
310+
311+
if err := c.ShouldBindJSON(&aiUsageReport); err != nil {
312+
msg := "failed to parse AI usage payload"
313+
fmt.Println(msg, err)
314+
c.JSON(http.StatusBadRequest, gin.H{
315+
"error": msg,
316+
})
317+
return
318+
}
319+
320+
if ok, err := PerformAuthz(userId, teamId.String(), *ScopeTeamRead); err != nil {
321+
msg := `couldn't perform authorization checks`
322+
fmt.Println(msg, err)
323+
c.JSON(http.StatusInternalServerError, gin.H{"error": msg})
324+
return
325+
} else if !ok {
326+
msg := fmt.Sprintf(`you don't have permissions for team [%s]`, teamId)
327+
c.JSON(http.StatusForbidden, gin.H{"error": msg})
328+
return
329+
}
330+
331+
if ok, err := PerformAuthz(userId, teamId.String(), *ScopeAppRead); err != nil {
332+
msg := `couldn't perform authorization checks`
333+
fmt.Println(msg, err)
334+
c.JSON(http.StatusInternalServerError, gin.H{"error": msg})
335+
return
336+
} else if !ok {
337+
msg := fmt.Sprintf(`you don't have permissions to read apps in team [%s]`, teamId)
338+
c.JSON(http.StatusForbidden, gin.H{"error": msg})
339+
return
340+
}
341+
342+
var team = new(Team)
343+
team.ID = &teamId
344+
345+
// insert metrics into clickhouse table
346+
metricsSelectStmt := sqlf.
347+
Select("? AS team_id", team.ID).
348+
Select("? AS timestamp", time.Now()).
349+
Select("? AS model", aiUsageReport.Model).
350+
Select("sumState(CAST(? AS UInt32)) AS input_token_count", aiUsageReport.InputTokens).
351+
Select("sumState(CAST(? AS UInt32)) AS output_token_count", aiUsageReport.OutputTokens)
352+
selectSQL := metricsSelectStmt.String()
353+
args := metricsSelectStmt.Args()
354+
defer metricsSelectStmt.Close()
355+
metricsInsertStmt := "INSERT INTO ai_metrics " + selectSQL
356+
357+
if err := server.Server.ChPool.Exec(ctx, metricsInsertStmt, args...); err != nil {
358+
msg := `failed to insert ai metrics into clickhouse`
359+
fmt.Println(msg, err)
360+
c.JSON(http.StatusInternalServerError, gin.H{
361+
"error": msg,
362+
})
363+
return
364+
}
365+
366+
c.JSON(http.StatusOK, gin.H{
367+
"ok": "done",
368+
})
369+
}

docs/CONTRIBUTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,3 +258,6 @@ set VERSION $(git cliff --bumped-version) && git tag -s $VERSION -m $VERSION &&
258258
- Public facing docs should be in [docs](../README.md) folder - API requests & responses, self host guide, sdk guides and so on
259259
- Main folder of subproject should link to main guide. ex: [frontend README](../../frontend/README.md) has link to self hosting and local dev guide
260260
- Non public facing docs can stay in sub folder. ex: [backend benchmarking README](../../backend/benchmarking/README.md) which describes its purpose
261+
262+
### Updating Documentation
263+
- When updating docs in the `docs` folder, the `docs-embeddings.json` file needs to be updated so that the AI assistant can reference them. To update the embedding, you should run `npm run generate-docs-embeddings` from inside the `frontend/dashboard` folder. You will need to have [setup AI Integration](../hosting/ai.md) before running the command so that it can access the embedding model and update the embedding file. This file needs to be committed as part of the same PR that updates the docs.

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ feature's documentation to understand its underlying mechanism and enhance your
4949
happens
5050
* [**App Size Monitoring**](features/feature-app-size-monitoring.md) — Monitor app size changes
5151
* [**Alert Notifications**](features/feature-alerts.md) — Receive Crash & ANR spike alerts and Daily Summaries for core app metrics.
52+
* [**Measure AI**](features/feature-ai.md) — Debug Crashes & ANRs with AI assistance.
5253

5354
# Configuration Options
5455

docs/features/feature-ai.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# AI
2+
3+
Measure makes it easy to debug Crashes and ANRs with AI assistance.
4+
5+
* [**Copy AI Context**](#copy-ai-context)
6+
* [**AI Integration**](#ai-integration)
7+
* [**Setting Up AI integration**](#setting-up-ai-integration)
8+
* [**Debugging with AI**](#debugging-with-ai)
9+
10+
## Copy AI Context
11+
On Crash and ANR details pages, you will see a `Copy AI Context` button. This allows you to copy the relevant stacktrace and session timeline to the copy clipboard for easy pasting into an external LLM interface of your choice.
12+
13+
This feature does not require setting up the AI Assitant and can be used directly.
14+
15+
## AI Assistant Integration
16+
This integration enables developers to get help with Crash/ANR debugging via our AI assitant.
17+
18+
### Setting up AI integration
19+
If you are a self hosted user, please set up AI Assistant integration if you haven't done so using this [guide](/docs/hosting/ai.md).
20+
21+
### Debugging with AI
22+
You will be able to see a `Debug With AI` button on Crash and ANR details pages. You can click it to open the assitant interface and start chatting.
23+
24+
You can attach the current exception context as well as the corresponding session timeline using the chat input toolbar.
25+
26+
You can also attach code files and images to help in your debugging workflow.
27+
28+

docs/hosting/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ Optionally, you can set up a Slack app if you want to recieve alert notications
159159
160160
Once your slack integration is set up, copy the values and enter in the relevant prompts. If you wish to ignore it, enter empty values and proceed.
161161
162+
Additionally, you can set up AI integration if you want to use the AI assistant features. Follow the below link to set it up:
163+
164+
- [Set up AI Assistant Integration](./ai.md)
165+
166+
Once your AI integration is set up, copy the values and enter in the relevant prompts. If you wish to ignore it, enter empty values and proceed.
167+
162168
Once completed, the install script will attempt to start all the Measure docker compose services. You should see a similar output.
163169
164170
<p align="center">

docs/hosting/ai.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Set up AI Gateway API key
2+
3+
Measure uses Vercel's AI Gateway to implement LLM based features.
4+
5+
You can sign up for an account and get your API key [here](https://vercel.com/ai-gateway).
6+
7+
8+
## Configure SMTP email settings for existing users
9+
10+
If you are upgrading from v0.8.x, you would need to manually configure the API key.
11+
12+
1. Edit the `self-host/.env` file.
13+
14+
2. Add the following environment variables as obtained from Vercel.
15+
16+
```sh
17+
AI_GATEWAY_API_KEY=your-key # change this
18+
```
19+
20+
3. Run the following command to shutdown all services.
21+
22+
```sh
23+
sudo docker compose \
24+
-f compose.yml \
25+
-f compose.prod.yml \
26+
--profile migrate \
27+
down
28+
```
29+
30+
4. Finally, run the `install.sh` script for the configuration to take effect.
31+
32+
```sh
33+
sudo ./install.sh
34+
```
35+
36+
[Go back to self host guide](./README.md)

frontend/dashboard/__tests__/pages/alerts_overview_test.tsx

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -200,22 +200,6 @@ describe('AlertsOverview Component', () => {
200200
const link = screen.getByRole('link', { name: /ID: alert1/i })
201201
expect(link).toBeInTheDocument()
202202
expect(link).toHaveAttribute('href', 'http://example.com/alert1')
203-
204-
// Find the table row that contains this link
205-
const row = link.closest('tr')
206-
expect(row).toBeInTheDocument()
207-
208-
// Simulate keyboard navigation (Enter) on the row
209-
await act(async () => {
210-
fireEvent.keyDown(row!, { key: 'Enter' })
211-
})
212-
expect(pushMock).toHaveBeenCalledWith('http://example.com/alert1')
213-
214-
// Simulate keyboard navigation (Space) on the row
215-
await act(async () => {
216-
fireEvent.keyDown(row!, { key: ' ' })
217-
})
218-
expect(pushMock).toHaveBeenCalledWith('http://example.com/alert1')
219203
})
220204

221205
describe('Pagination offset handling', () => {

frontend/dashboard/__tests__/pages/bug_reports_overview_test.tsx

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -238,22 +238,6 @@ describe('BugReportsOverview Component', () => {
238238
const link = screen.getByRole('link', { name: /ID: bug1/i })
239239
expect(link).toBeInTheDocument()
240240
expect(link).toHaveAttribute('href', '/123/bug_reports/app1/bug1')
241-
242-
// Find the table row that contains this link
243-
const row = link.closest('tr')
244-
expect(row).toBeInTheDocument()
245-
246-
// Simulate keyboard navigation (Enter) on the row
247-
await act(async () => {
248-
fireEvent.keyDown(row!, { key: 'Enter' })
249-
})
250-
expect(pushMock).toHaveBeenCalledWith('/123/bug_reports/app1/bug1')
251-
252-
// Simulate keyboard navigation (Space) on the row
253-
await act(async () => {
254-
fireEvent.keyDown(row!, { key: ' ' })
255-
})
256-
expect(pushMock).toHaveBeenCalledWith('/123/bug_reports/app1/bug1')
257241
})
258242

259243
it('handles bug reports with no description properly', async () => {

0 commit comments

Comments
 (0)