Skip to content

Commit eab0d0b

Browse files
committed
feat(frontend): implement Measure AI chat
closes #2725
1 parent 6a38e09 commit eab0d0b

File tree

89 files changed

+20066
-5160
lines changed

Some content is hidden

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

89 files changed

+20066
-5160
lines changed

backend/api/main.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,41 @@ func main() {
137137
teams.PATCH(":id/slack/status", measure.UpdateTeamSlackStatus)
138138
}
139139

140+
mcp := r.Group("/mcp")
141+
{
142+
mcp.GET("teams/:id/keys", measure.ValidateAccessToken(), measure.GetMcpKeys)
143+
mcp.POST("teams/:id/keys", measure.ValidateAccessToken(), measure.CreateMcpKey)
144+
mcp.PATCH("teams/:id/keys/:keyId/revoke", measure.ValidateAccessToken(), measure.RevokeMcpKey)
145+
mcp.GET("keys/teamId", measure.ValidateMcpKey(), measure.GetTeamIdForMcpKey)
146+
mcp.POST("teams/:id/usage/ai", measure.ValidateAccessTokenOrMcpKey(), measure.ReportAiUsage)
147+
mcp.GET("user", measure.ValidateAccessTokenOrMcpKey(), measure.GetUser)
148+
mcp.GET("teams/:id/apps", measure.ValidateAccessTokenOrMcpKey(), measure.GetTeamApps)
149+
mcp.GET("apps/:id/journey", measure.ValidateAccessTokenOrMcpKey(), measure.GetAppJourney)
150+
mcp.GET("apps/:id/metrics", measure.ValidateAccessTokenOrMcpKey(), measure.GetAppMetrics)
151+
mcp.GET("apps/:id/filters", measure.ValidateAccessTokenOrMcpKey(), measure.GetAppFilters)
152+
mcp.GET("apps/:id/crashGroups", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashOverview)
153+
mcp.GET("apps/:id/crashGroups/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashOverviewPlotInstances)
154+
mcp.GET("apps/:id/crashGroups/:crashGroupId/crashes", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashDetailCrashes)
155+
mcp.GET("apps/:id/crashGroups/:crashGroupId/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashDetailPlotInstances)
156+
mcp.GET("apps/:id/crashGroups/:crashGroupId/plots/distribution", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashDetailAttributeDistribution)
157+
mcp.GET("apps/:id/anrGroups", measure.ValidateAccessTokenOrMcpKey(), measure.GetANROverview)
158+
mcp.GET("apps/:id/anrGroups/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetANROverviewPlotInstances)
159+
mcp.GET("apps/:id/anrGroups/:anrGroupId/anrs", measure.ValidateAccessTokenOrMcpKey(), measure.GetANRDetailANRs)
160+
mcp.GET("apps/:id/anrGroups/:anrGroupId/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetANRDetailPlotInstances)
161+
mcp.GET("apps/:id/anrGroups/:anrGroupId/plots/distribution", measure.ValidateAccessTokenOrMcpKey(), measure.GetANRDetailAttributeDistribution)
162+
mcp.GET("apps/:id/sessions", measure.ValidateAccessTokenOrMcpKey(), measure.GetSessionsOverview)
163+
mcp.GET("apps/:id/sessions/:sessionId", measure.ValidateAccessTokenOrMcpKey(), measure.GetSession)
164+
mcp.GET("apps/:id/sessions/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetSessionsOverviewPlotInstances)
165+
mcp.GET("apps/:id/spans/roots/names", measure.ValidateAccessTokenOrMcpKey(), measure.GetRootSpanNames)
166+
mcp.GET("apps/:id/spans", measure.ValidateAccessTokenOrMcpKey(), measure.GetSpansForSpanName)
167+
mcp.GET("apps/:id/spans/plots/metrics", measure.ValidateAccessTokenOrMcpKey(), measure.GetMetricsPlotForSpanName)
168+
mcp.GET("apps/:id/traces/:traceId", measure.ValidateAccessTokenOrMcpKey(), measure.GetTrace)
169+
mcp.GET("apps/:id/bugReports", measure.ValidateAccessTokenOrMcpKey(), measure.GetBugReportsOverview)
170+
mcp.GET("apps/:id/bugReports/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetBugReportsInstancesPlot)
171+
mcp.GET("apps/:id/bugReports/:bugReportId", measure.ValidateAccessTokenOrMcpKey(), measure.GetBugReport)
172+
mcp.GET("apps/:id/alerts", measure.ValidateAccessTokenOrMcpKey(), measure.GetAlertsOverview)
173+
}
174+
140175
slack := r.Group("/slack")
141176
{
142177
slack.POST("/connect", measure.ConnectTeamSlack)

backend/api/measure/auth.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,27 @@ func extractToken(c *gin.Context) (token string) {
5656
return
5757
}
5858

59+
// extractMcpKey extracts the MCP key
60+
// from the Authorization header.
61+
func extractMcpKey(c *gin.Context) (token string) {
62+
authHeader := c.GetHeader("Authorization")
63+
splitToken := strings.Split(authHeader, "Bearer ")
64+
65+
if len(splitToken) != 2 {
66+
c.AbortWithStatus(http.StatusUnauthorized)
67+
return
68+
}
69+
70+
token = strings.TrimSpace(splitToken[1])
71+
72+
if token == "" {
73+
c.AbortWithStatus((http.StatusUnauthorized))
74+
return
75+
}
76+
77+
return
78+
}
79+
5980
// extractRefreshToken extracts the refresh token
6081
// from the cookie or Authorization header
6182
func extractRefreshToken(c *gin.Context) (token string) {
@@ -145,6 +166,100 @@ func ValidateAccessToken() gin.HandlerFunc {
145166
}
146167
}
147168

169+
// ValidateMcpKey validates the Measure MCP key.
170+
func ValidateMcpKey() gin.HandlerFunc {
171+
return func(c *gin.Context) {
172+
key := extractMcpKey(c)
173+
174+
userId, teamId, err := DecodeMcpKey(key)
175+
if err != nil {
176+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
177+
"error": "invalid MCP key",
178+
})
179+
return
180+
}
181+
182+
if userId == nil {
183+
msg := "no user found for this MCP key"
184+
fmt.Println(msg)
185+
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg})
186+
return
187+
}
188+
189+
if teamId == nil {
190+
msg := "no team found for this MCP key"
191+
fmt.Println(msg)
192+
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg})
193+
return
194+
}
195+
196+
c.Set("userId", userId.String())
197+
c.Set("teamId", teamId.String())
198+
c.Next()
199+
}
200+
}
201+
202+
// ValidateAccessTokenOrMcpKey validates the Measure access token or MCP key. If either one succeeds, the request is allowed.
203+
func ValidateAccessTokenOrMcpKey() gin.HandlerFunc {
204+
return func(c *gin.Context) {
205+
token := extractToken(c)
206+
207+
// Try access token validation first
208+
accessToken, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
209+
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
210+
err := fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
211+
return nil, err
212+
}
213+
214+
return server.Server.Config.AccessTokenSecret, nil
215+
})
216+
217+
// If access token is valid, use it
218+
if err == nil {
219+
if claims, ok := accessToken.Claims.(jwt.MapClaims); ok {
220+
sessionId := claims["jti"]
221+
c.Set("sessionId", sessionId)
222+
223+
userId := claims["sub"]
224+
c.Set("userId", userId)
225+
226+
c.Next()
227+
return
228+
}
229+
}
230+
231+
// Access token failed, try MCP key
232+
key := extractMcpKey(c)
233+
userId, teamId, err := DecodeMcpKey(key)
234+
if err != nil {
235+
// Both validations failed
236+
fmt.Println("both access token and MCP key validation failed")
237+
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
238+
"error": "invalid access token or MCP key",
239+
})
240+
return
241+
}
242+
243+
if userId == nil {
244+
msg := "no user found for this MCP key"
245+
fmt.Println(msg)
246+
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg})
247+
return
248+
}
249+
250+
if teamId == nil {
251+
msg := "no team found for this MCP key"
252+
fmt.Println(msg)
253+
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg})
254+
return
255+
}
256+
257+
c.Set("userId", userId.String())
258+
c.Set("teamId", teamId.String())
259+
c.Next()
260+
}
261+
}
262+
148263
// ValidateRefreshToken validates the Measure refresh token.
149264
func ValidateRefreshToken() gin.HandlerFunc {
150265
return func(c *gin.Context) {
@@ -967,6 +1082,58 @@ func RefreshToken(c *gin.Context) {
9671082
})
9681083
}
9691084

1085+
// GetUser returns the current user information
1086+
func GetUser(c *gin.Context) {
1087+
userId := c.GetString("userId")
1088+
1089+
ctx := c.Request.Context()
1090+
1091+
if userId == "" {
1092+
c.JSON(http.StatusUnauthorized, gin.H{
1093+
"error": "Not authenticated",
1094+
})
1095+
return
1096+
}
1097+
1098+
user := &User{
1099+
ID: &userId,
1100+
}
1101+
1102+
ownTeam, err := user.getOwnTeam(ctx)
1103+
if err != nil {
1104+
msg := "Unable to get user's team"
1105+
fmt.Println(msg, err)
1106+
c.JSON(http.StatusInternalServerError, gin.H{
1107+
"error": msg,
1108+
})
1109+
return
1110+
}
1111+
1112+
err = user.getUserDetails(ctx)
1113+
1114+
if err != nil {
1115+
msg := "Unable to get user details"
1116+
fmt.Println(msg, err)
1117+
c.JSON(http.StatusInternalServerError, gin.H{
1118+
"error": msg,
1119+
})
1120+
return
1121+
}
1122+
1123+
c.JSON(http.StatusOK, gin.H{
1124+
"user": gin.H{
1125+
"id": userId,
1126+
"own_team_id": ownTeam.ID,
1127+
"name": user.Name,
1128+
"email": user.Email,
1129+
"confirmed_at": user.ConfirmedAt,
1130+
"last_sign_in_at": user.LastSignInAt,
1131+
"created_at": user.CreatedAt,
1132+
"updated_at": user.UpdatedAt,
1133+
},
1134+
})
1135+
}
1136+
9701137
// GetSession returns the current session information
9711138
func GetAuthSession(c *gin.Context) {
9721139
userId := c.GetString("userId")

0 commit comments

Comments
 (0)