NewRelic Integration — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Instrument all xtrakt services with NewRelic APM/Browser agents and expose an in-app monitoring dashboard in xtrakt-sso-fe by proxying NerdGraph via xtrakt-platform-be.
Architecture: A single NR account/license key (stored in GCP Secret Manager as xtrakt-json-newrelic). Each .NET backend service runs the NR APM agent via environment variables + Dockerfile layer. xtrakt-sso-fe gets the NR Browser Agent snippet in index.html. xtrakt-platform-be exposes GET /monitoring/dashboard?period= which calls NerdGraph server-side (NR API key never reaches the browser). An Angular NewRelicDashboardComponent calls this endpoint and renders Chart.js charts. The dashboard is admin-only.
Tech Stack: NewRelic .NET APM Agent, NewRelic Browser Agent JS snippet, NerdGraph GraphQL API, Chart.js (already installed by Task 2), Angular 21 standalone components.
All backend commands: run from the relevant service directory.
All frontend commands: run from xtrakt-sso-fe/src/.
Fix from Plan 2 — Apply before running Plan 2 tasks:
AdminServicein Plan 2 incorrectly references a non-existentEnvironmentService. Insrc/app/services/admin.service.ts, replace the two inject/import lines:// REMOVE:import { EnvironmentService } from './environment.service';private readonly apiUrl = inject(EnvironmentService).ssoApiUrl;// ADD:import { environment } from '../../environments/environment';private readonly apiUrl = environment.ssoApiUrl;Remove
injectfrom the@angular/coreimport if it's no longer used elsewhere in the file.
File Map
Backend — xtrakt-platform-be
| Action | Path |
|---|---|
| Create | src/xTrakt.Platform.Application/Services/Interfaces/INewRelicService.cs |
| Create | src/xTrakt.Platform.Application/Services/NewRelicService.cs |
| Modify | src/xTrakt.Platform.Application/Startup.cs |
| Create | src/xTrakt.Platform.Application/Handlers/Monitoring/GetDashboardDataHandler.cs |
| Create | src/xTrakt.Platform.Application/Handlers/Monitoring/GetDashboardDataRequest.cs |
| Create | src/xTrakt.Platform.Application/Handlers/Monitoring/GetDashboardDataResponse.cs |
| Create | src/Web/Controllers/MonitoringController.cs |
Each .NET backend service (xtrakt-sso-be, xtrakt-platform-be, xtrakt-billing-be, xtrakt-vision-be, xtrakt-api-be)
| Action | Path |
|---|---|
| Modify | Dockerfile (add NR agent layer) |
| Create | newrelic.config (agent config file) |
Frontend — xtrakt-sso-fe/src
| Action | Path |
|---|---|
| Modify | src/index.html |
| Create | src/app/admin/newrelic-dashboard.component.ts |
| Create | src/app/admin/newrelic-dashboard.component.html |
| Create | src/app/admin/newrelic-dashboard.component.scss |
| Modify | src/app/app.routes.ts |
| Modify | src/app/menu/menu.component.ts |
| Modify | src/assets/i18n/en.json |
| Modify | src/assets/i18n/pt-BR.json |
Task 1: NewRelic Account Setup (Manual — one-time)
- Step 1: Create NewRelic account
Go to newrelic.com → sign up for a free account.
After creation:
-
Note your Account ID (shown in the URL:
one.newrelic.com/launcher/...?accountId=XXXXXXX) -
Generate a License Key: Account Settings → API Keys → Create key of type
INGEST - LICENSE -
Generate a User API Key: Account Settings → API Keys → Create key of type
USER(needed for NerdGraph queries) -
Step 2: Store secrets in GCP Secret Manager
echo -n '{"license_key":"<PASTE_NR_LICENSE_KEY>","user_api_key":"<PASTE_NR_USER_API_KEY>","account_id":"<PASTE_ACCOUNT_ID>"}' \
| gcloud secrets create xtrakt-json-newrelic --data-file=-
For existing deployments, add a new version:
echo -n '{"license_key":"...","user_api_key":"...","account_id":"..."}' \
| gcloud secrets versions add xtrakt-json-newrelic --data-file=-
- Step 3: Set local dev config
In each backend service's appsettings.Development.json, add:
{
"NewRelic": {
"LicenseKey": "<NR_LICENSE_KEY>",
"UserApiKey": "<NR_USER_API_KEY>",
"AccountId": "<NR_ACCOUNT_ID>"
}
}
These values come from the NR account created above.
Task 2: APM Agent — Each .NET Backend Service
Repeat this task for each of the five backend services:
xtrakt-sso-bextrakt-platform-bextrakt-billing-bextrakt-vision-bextrakt-api-be
The service name used in NR is xtrakt-<service> (e.g., xtrakt-sso-be).
Commands run from the service root (e.g., xtrakt-sso-be/).
- Step 1: Create newrelic.config in the service root
<?xml version="1.0"?>
<!-- xtrakt-sso-be/newrelic.config (adjust app_name per service) -->
<configuration xmlns="urn:newrelic-config" agentEnabled="true">
<service licenseKey="${NEW_RELIC_LICENSE_KEY}" />
<application>
<name>${NEW_RELIC_APP_NAME}</name>
</application>
<log level="info" />
<distributedTracing enabled="true" />
</configuration>
The ${NEW_RELIC_APP_NAME} and ${NEW_RELIC_LICENSE_KEY} are replaced by environment variables at runtime. The app_name for each service:
| Service | app_name env var value |
|---|---|
| xtrakt-sso-be | xtrakt-sso-be |
| xtrakt-platform-be | xtrakt-platform-be |
| xtrakt-billing-be | xtrakt-billing-be |
| xtrakt-vision-be | xtrakt-vision-be |
| xtrakt-api-be | xtrakt-api-be |
- Step 2: Modify Dockerfile to install NR agent
Find the existing Dockerfile in the service root. The multi-stage build typically ends with a FROM mcr.microsoft.com/dotnet/aspnet runtime stage. Add the NR agent download and config copy:
# ---- existing build stages above ----
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
# NewRelic .NET APM Agent
RUN apt-get update && apt-get install -y curl && \
curl -L https://download.newrelic.com/dot_net_agent/latest_release/newrelic-dotnet-agent_amd64.deb \
-o /tmp/newrelic.deb && \
dpkg -i /tmp/newrelic.deb && \
rm /tmp/newrelic.deb && \
apt-get clean
# Copy our config (env vars override values at runtime)
COPY newrelic.config /usr/local/newrelic-dotnet-agent/newrelic.config
ENV CORECLR_ENABLE_PROFILING=1 \
CORECLR_PROFILER="{36032161-FFC0-4B61-B559-F6C5D41BAE5A}" \
CORECLR_PROFILER_PATH=/usr/local/newrelic-dotnet-agent/libNewRelicProfiler.so \
NEWRELIC_INSTALL_PATH=/usr/local/newrelic-dotnet-agent \
NEW_RELIC_HOME=/usr/local/newrelic-dotnet-agent
# ... rest of existing Dockerfile (COPY --from=build, ENTRYPOINT, etc.)
- Step 3: Set Cloud Run environment variables
For each service's Cloud Run deployment (via GitHub Actions or gcloud CLI), ensure these env vars are set:
NEW_RELIC_LICENSE_KEY=<from GCP Secret Manager>
NEW_RELIC_APP_NAME=xtrakt-sso-be # change per service
In the GitHub Actions workflow (.github/workflows/deploy-cloud-run.yml), add under the gcloud run deploy command:
--set-env-vars="NEW_RELIC_LICENSE_KEY=${{ secrets.NR_LICENSE_KEY }},NEW_RELIC_APP_NAME=xtrakt-sso-be"
Add NR_LICENSE_KEY to GitHub repository secrets.
- Step 4: Commit per service
git add Dockerfile newrelic.config
git commit -m "feat(infra): add NewRelic APM agent"
Task 3: Browser Agent — xtrakt-sso-fe
Commands run from xtrakt-sso-fe/src/.
The NR Browser Agent JS snippet must be placed in <head> of index.html. Generate the snippet from your NR account: Browser → Add Data → (choose your app name xtrakt-sso-fe) → Copy/paste snippet.
- Step 1: Get Browser Agent snippet
In NewRelic UI:
- Go to Add data → Browser
- App name:
xtrakt-sso-fe - Select Copy/paste JavaScript code method
- Copy the generated
<script>block (it looks likewindow.NREUM||...)
- Step 2: Add snippet to index.html
In src/index.html, paste the snippet as the first child of <head>:
<!doctype html>
<html lang="en">
<head>
<!-- NewRelic Browser Agent - paste generated snippet here -->
<script type="text/javascript">
window.NREUM||(NREUM={});NREUM.init={...}
/* ... full snippet from NR UI ... */
</script>
<meta charset="utf-8">
<title>XtraktFrontend</title>
<!-- ... rest of head ... -->
</head>
- Step 3: Commit
git add src/index.html
git commit -m "feat(fe): add NewRelic Browser Agent snippet"
Task 4: NerdGraph Proxy — Application Layer (xtrakt-platform-be)
Commands run from xtrakt-platform-be/.
- Step 1: Create INewRelicService
// src/xTrakt.Platform.Application/Services/Interfaces/INewRelicService.cs
namespace xTrakt.Platform.Application.Services.Interfaces;
public record NrMetricPoint(long Timestamp, double Value);
public record NrDashboardData(
Dictionary<string, List<NrMetricPoint>> Throughput, // per app name
Dictionary<string, List<NrMetricPoint>> ErrorRate, // per app name
Dictionary<string, double> Apdex, // per app name
Dictionary<string, (double P50, double P90, double P99)> ResponseTime,
double AvgLcp,
double AvgFid,
double AvgCls);
public interface INewRelicService
{
Task<NrDashboardData> GetDashboardDataAsync(string period);
}
- Step 2: Create NewRelicService
The service calls NerdGraph (https://api.newrelic.com/graphql) using the User API Key. Each metric is a separate NRQL query. The account ID and API key come from IConfiguration.
// src/xTrakt.Platform.Application/Services/NewRelicService.cs
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using xTrakt.Platform.Application.Services.Interfaces;
namespace xTrakt.Platform.Application.Services;
public class NewRelicService : INewRelicService
{
private const string NerdGraphUrl = "https://api.newrelic.com/graphql";
private static readonly string[] AppNames =
[
"xtrakt-sso-be", "xtrakt-platform-be", "xtrakt-billing-be",
"xtrakt-vision-be", "xtrakt-api-be"
];
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<NewRelicService> _logger;
public NewRelicService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<NewRelicService> logger)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_logger = logger;
}
public async Task<NrDashboardData> GetDashboardDataAsync(string period)
{
var userApiKey = _configuration["NewRelic:UserApiKey"]
?? throw new InvalidOperationException("NewRelic:UserApiKey not configured");
var accountId = _configuration["NewRelic:AccountId"]
?? throw new InvalidOperationException("NewRelic:AccountId not configured");
var since = PeriodToSince(period);
var appFilter = string.Join(", ", AppNames.Select(a => $"'{a}'"));
var throughput = new Dictionary<string, List<NrMetricPoint>>();
var errorRate = new Dictionary<string, double>();
var apdex = new Dictionary<string, double>();
var responseTime = new Dictionary<string, (double, double, double)>();
foreach (var app in AppNames)
{
throughput[app] = await QueryTimeseriesAsync(
userApiKey, accountId,
$"SELECT rate(count(*), 1 MINUTE) FROM Transaction WHERE appName = '{app}' SINCE {since} TIMESERIES AUTO");
errorRate[app] = await QuerySingleValueAsync(
userApiKey, accountId,
$"SELECT percentage(count(*), WHERE error IS true) FROM Transaction WHERE appName = '{app}' SINCE {since}");
apdex[app] = await QuerySingleValueAsync(
userApiKey, accountId,
$"SELECT apdex(duration, 0.5) FROM Transaction WHERE appName = '{app}' SINCE {since}");
var pcts = await QueryPercentileAsync(
userApiKey, accountId,
$"SELECT percentile(duration, 50, 90, 99) FROM Transaction WHERE appName = '{app}' SINCE {since}");
responseTime[app] = pcts;
}
var lcp = await QuerySingleValueAsync(userApiKey, accountId,
$"SELECT average(largestContentfulPaint) FROM PageViewTiming SINCE {since}");
var fid = await QuerySingleValueAsync(userApiKey, accountId,
$"SELECT average(firstInputDelay) FROM PageViewTiming SINCE {since}");
var cls = await QuerySingleValueAsync(userApiKey, accountId,
$"SELECT average(cumulativeLayoutShift) FROM PageViewTiming SINCE {since}");
return new NrDashboardData(
throughput,
errorRate.ToDictionary(k => k.Key, k => new List<NrMetricPoint> { new(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), k.Value) }),
apdex,
responseTime,
lcp, fid, cls);
}
private async Task<List<NrMetricPoint>> QueryTimeseriesAsync(string apiKey, string accountId, string nrql)
{
var result = new List<NrMetricPoint>();
try
{
var json = await ExecuteNrqlAsync(apiKey, accountId, nrql);
var root = JsonDocument.Parse(json).RootElement;
var results = root.GetProperty("data").GetProperty("actor").GetProperty("account")
.GetProperty("nrql").GetProperty("results");
foreach (var item in results.EnumerateArray())
{
var ts = item.TryGetProperty("beginTimeSeconds", out var t) ? t.GetInt64() * 1000 : 0;
var val = item.TryGetProperty("result", out var v) ? v.GetDouble() : 0;
result.Add(new NrMetricPoint(ts, val));
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "NerdGraph NRQL timeseries query failed: {Nrql}", nrql);
}
return result;
}
private async Task<double> QuerySingleValueAsync(string apiKey, string accountId, string nrql)
{
try
{
var json = await ExecuteNrqlAsync(apiKey, accountId, nrql);
var root = JsonDocument.Parse(json).RootElement;
var results = root.GetProperty("data").GetProperty("actor").GetProperty("account")
.GetProperty("nrql").GetProperty("results");
var first = results.EnumerateArray().FirstOrDefault();
if (first.ValueKind != JsonValueKind.Undefined)
{
foreach (var prop in first.EnumerateObject())
if (prop.Value.ValueKind == JsonValueKind.Number)
return prop.Value.GetDouble();
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "NerdGraph NRQL single-value query failed: {Nrql}", nrql);
}
return 0;
}
private async Task<(double P50, double P90, double P99)> QueryPercentileAsync(string apiKey, string accountId, string nrql)
{
try
{
var json = await ExecuteNrqlAsync(apiKey, accountId, nrql);
var root = JsonDocument.Parse(json).RootElement;
var results = root.GetProperty("data").GetProperty("actor").GetProperty("account")
.GetProperty("nrql").GetProperty("results");
var first = results.EnumerateArray().FirstOrDefault();
if (first.ValueKind != JsonValueKind.Undefined)
{
double p50 = 0, p90 = 0, p99 = 0;
foreach (var prop in first.EnumerateObject())
{
if (prop.Name.Contains("50")) p50 = prop.Value.GetDouble();
else if (prop.Name.Contains("90")) p90 = prop.Value.GetDouble();
else if (prop.Name.Contains("99")) p99 = prop.Value.GetDouble();
}
return (p50, p90, p99);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "NerdGraph percentile query failed: {Nrql}", nrql);
}
return (0, 0, 0);
}
private async Task<string> ExecuteNrqlAsync(string apiKey, string accountId, string nrql)
{
var graphqlQuery = $$"""
{
actor {
account(id: {{accountId}}) {
nrql(query: "{{nrql.Replace("\"", "\\\"")}}" ) {
results
}
}
}
}
""";
var body = JsonSerializer.Serialize(new { query = graphqlQuery });
var http = _httpClientFactory.CreateClient();
http.DefaultRequestHeaders.Add("Api-Key", apiKey);
var response = await http.PostAsync(NerdGraphUrl,
new StringContent(body, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
private static string PeriodToSince(string period) => period switch
{
"1h" => "1 hour ago",
"6h" => "6 hours ago",
"7d" => "7 days ago",
_ => "24 hours ago"
};
}
- Step 3: Register in Application/Startup.cs
Add these lines after the existing services.AddScoped<IVisionService, VisionService>(); line:
services.AddHttpClient();
services.AddScoped<INewRelicService, NewRelicService>();
Add the using:
using xTrakt.Platform.Application.Services.Interfaces;
- Step 4: Build to verify
dotnet build src/xTrakt.Platform.Application/xTrakt.Platform.Application.csproj
Expected: Build succeeded, 0 errors.
- Step 5: Commit
git add src/xTrakt.Platform.Application/
git commit -m "feat(application): add INewRelicService and NewRelicService NerdGraph proxy"
Task 5: NerdGraph Proxy — Handler and Controller (xtrakt-platform-be)
Commands run from xtrakt-platform-be/.
- Step 1: Create GetDashboardDataRequest and GetDashboardDataResponse
// src/xTrakt.Platform.Application/Handlers/Monitoring/GetDashboardDataRequest.cs
namespace xTrakt.Platform.Application.Handlers.Monitoring;
public class GetDashboardDataRequest
{
public string Period { get; set; } = "24h";
}
// src/xTrakt.Platform.Application/Handlers/Monitoring/GetDashboardDataResponse.cs
namespace xTrakt.Platform.Application.Handlers.Monitoring;
public class GetDashboardDataResponse
{
public Dictionary<string, List<ThroughputPoint>> Throughput { get; set; } = new();
public Dictionary<string, double> ErrorRate { get; set; } = new();
public Dictionary<string, double> Apdex { get; set; } = new();
public Dictionary<string, ResponseTimeStats> ResponseTime { get; set; } = new();
public WebVitals WebVitals { get; set; } = new();
}
public record ThroughputPoint(long Timestamp, double Value);
public record ResponseTimeStats(double P50, double P90, double P99);
public class WebVitals
{
public double Lcp { get; set; }
public double Fid { get; set; }
public double Cls { get; set; }
}
- Step 2: Create GetDashboardDataHandler
// src/xTrakt.Platform.Application/Handlers/Monitoring/GetDashboardDataHandler.cs
using Microsoft.Extensions.Logging;
using xTrakt.Application;
using xTrakt.Platform.Application.Services.Interfaces;
namespace xTrakt.Platform.Application.Handlers.Monitoring;
public class GetDashboardDataHandler : HandlerBase<GetDashboardDataRequest, GetDashboardDataResponse>
{
private readonly INewRelicService _newRelicService;
public GetDashboardDataHandler(
ILogger<HandlerBase<GetDashboardDataRequest, GetDashboardDataResponse>> logger,
INewRelicService newRelicService) : base(logger)
{
_newRelicService = newRelicService;
}
protected override async Task HandleAsync()
{
var data = await _newRelicService.GetDashboardDataAsync(Input.Period);
Response.Data = new GetDashboardDataResponse
{
Throughput = data.Throughput.ToDictionary(
k => k.Key,
k => k.Value.Select(p => new ThroughputPoint(p.Timestamp, p.Value)).ToList()),
ErrorRate = data.ErrorRate,
Apdex = data.Apdex,
ResponseTime = data.ResponseTime.ToDictionary(
k => k.Key,
k => new ResponseTimeStats(k.Value.P50, k.Value.P90, k.Value.P99)),
WebVitals = new WebVitals
{
Lcp = data.AvgLcp,
Fid = data.AvgFid,
Cls = data.AvgCls
}
};
}
protected override Task ValidateAsync() => Task.CompletedTask;
}
- Step 3: Create MonitoringController
// src/Web/Controllers/MonitoringController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Validation.AspNetCore;
using xTrakt.Base;
using xTrakt.Platform.Application.Handlers.Monitoring;
using xTrakt.WebApi;
namespace Web.Controllers;
[ApiController]
[Route("monitoring")]
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
public class MonitoringController : xTraktControllerBase<MonitoringController>
{
public MonitoringController(IServiceContext serviceContext, ILogger<MonitoringController> logger)
: base(serviceContext, logger) { }
[HttpGet("dashboard")]
public async Task<IServiceResponse<GetDashboardDataResponse>> GetDashboardAsync(
[FromServices] GetDashboardDataHandler handler,
[FromQuery] string period = "24h")
=> await handler.ProcessAsync(new GetDashboardDataRequest { Period = period });
}
- Step 4: Build full solution
dotnet build
Expected: 0 errors.
- Step 5: Commit
git add src/xTrakt.Platform.Application/Handlers/Monitoring/ src/Web/Controllers/MonitoringController.cs
git commit -m "feat(web): add MonitoringController GET /monitoring/dashboard proxy to NerdGraph"
Task 6: Frontend — NewRelicDashboardComponent
All commands from xtrakt-sso-fe/src/.
Note: chart.js was installed in Task 7 of Plan 2. If running Plan 3 independently, run npm install chart.js first.
- Step 1: Create NewRelicDashboardComponent TypeScript
// src/app/admin/newrelic-dashboard.component.ts
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClient, HttpParams } from '@angular/common/http';
import { TranslocoModule } from '@jsverse/transloco';
import { Chart, registerables } from 'chart.js';
import { environment } from '../../environments/environment';
Chart.register(...registerables);
interface ThroughputPoint { timestamp: number; value: number; }
interface ResponseTimeStats { p50: number; p90: number; p99: number; }
interface WebVitals { lcp: number; fid: number; cls: number; }
interface DashboardData {
throughput: { [app: string]: ThroughputPoint[] };
errorRate: { [app: string]: number };
apdex: { [app: string]: number };
responseTime: { [app: string]: ResponseTimeStats };
webVitals: WebVitals;
}
@Component({
selector: 'app-newrelic-dashboard',
standalone: true,
imports: [CommonModule, TranslocoModule],
templateUrl: './newrelic-dashboard.component.html',
styleUrls: ['./newrelic-dashboard.component.scss'],
})
export class NewRelicDashboardComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('throughputCanvas') throughputCanvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('errorCanvas') errorCanvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('apdexCanvas') apdexCanvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('responseCanvas') responseCanvas!: ElementRef<HTMLCanvasElement>;
period = '24h';
data: DashboardData | null = null;
loading = true;
error = false;
private charts: Chart[] = [];
private readonly apiUrl = environment.platformApiUrl;
readonly COLORS = ['#3b82f6', '#3dba7e', '#f59e0b', '#e05c5c', '#8b5cf6'];
readonly appNames = ['xtrakt-sso-be', 'xtrakt-platform-be', 'xtrakt-billing-be', 'xtrakt-vision-be', 'xtrakt-api-be'];
constructor(private http: HttpClient) {}
ngOnInit(): void { this.load(); }
ngAfterViewInit(): void {}
setPeriod(p: string): void {
this.period = p;
this.load();
}
private load(): void {
this.loading = true;
this.error = false;
const params = new HttpParams().set('period', this.period);
this.http
.get(`${this.apiUrl}/monitoring/dashboard`, { params, responseType: 'text' as 'json' })
.subscribe({
next: (res) => {
const parsed = JSON.parse(res as string);
this.data = parsed.data as DashboardData;
this.loading = false;
setTimeout(() => this.buildCharts(), 0);
},
error: () => { this.loading = false; this.error = true; },
});
}
private buildCharts(): void {
this.charts.forEach((c) => c.destroy());
this.charts = [];
if (!this.data) return;
// Throughput line chart
const throughputLabels = this.getTimeLabels();
const throughputDatasets = this.appNames.map((app, i) => ({
label: app,
data: (this.data!.throughput[app] ?? []).map((p) => p.value),
borderColor: this.COLORS[i],
backgroundColor: 'transparent',
tension: 0.4,
}));
this.charts.push(new Chart(this.throughputCanvas.nativeElement, {
type: 'line',
data: { labels: throughputLabels, datasets: throughputDatasets },
options: { responsive: true, plugins: { legend: { position: 'top' } }, scales: { y: { beginAtZero: true } } },
}));
// Error rate bar chart
this.charts.push(new Chart(this.errorCanvas.nativeElement, {
type: 'bar',
data: {
labels: this.appNames,
datasets: [{
label: 'Error Rate %',
data: this.appNames.map((a) => this.data!.errorRate[a] ?? 0),
backgroundColor: this.COLORS,
}],
},
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, max: 100 } } },
}));
// Apdex score bar chart
this.charts.push(new Chart(this.apdexCanvas.nativeElement, {
type: 'bar',
data: {
labels: this.appNames,
datasets: [{
label: 'Apdex',
data: this.appNames.map((a) => this.data!.apdex[a] ?? 0),
backgroundColor: this.appNames.map((a) => {
const val = this.data!.apdex[a] ?? 0;
return val >= 0.94 ? '#3dba7e' : val >= 0.85 ? '#f59e0b' : '#e05c5c';
}),
}],
},
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, max: 1 } } },
}));
// Response time P50/P90/P99 bar chart
this.charts.push(new Chart(this.responseCanvas.nativeElement, {
type: 'bar',
data: {
labels: this.appNames,
datasets: [
{ label: 'P50', data: this.appNames.map((a) => this.data!.responseTime[a]?.p50 ?? 0), backgroundColor: '#3b82f6' },
{ label: 'P90', data: this.appNames.map((a) => this.data!.responseTime[a]?.p90 ?? 0), backgroundColor: '#f59e0b' },
{ label: 'P99', data: this.appNames.map((a) => this.data!.responseTime[a]?.p99 ?? 0), backgroundColor: '#e05c5c' },
],
},
options: { responsive: true, plugins: { legend: { position: 'top' } }, scales: { y: { beginAtZero: true } } },
}));
}
private getTimeLabels(): string[] {
// Derive labels from the first app's throughput timestamps
const app = this.appNames.find((a) => (this.data!.throughput[a] ?? []).length > 0);
if (!app) return [];
return (this.data!.throughput[app] ?? []).map((p) =>
new Date(p.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
);
}
get webVitals(): WebVitals {
return this.data?.webVitals ?? { lcp: 0, fid: 0, cls: 0 };
}
lcpStatus(v: number): string { return v <= 2500 ? 'good' : v <= 4000 ? 'needs-improvement' : 'poor'; }
fidStatus(v: number): string { return v <= 100 ? 'good' : v <= 300 ? 'needs-improvement' : 'poor'; }
clsStatus(v: number): string { return v <= 0.1 ? 'good' : v <= 0.25 ? 'needs-improvement' : 'poor'; }
ngOnDestroy(): void { this.charts.forEach((c) => c.destroy()); }
}
- Step 2: Create NewRelicDashboardComponent HTML
<!-- src/app/admin/newrelic-dashboard.component.html -->
<div class="page-container">
<div class="page-header">
<h1 class="page-title" transloco="menu.monitoring.newrelic"></h1>
<div class="period-pills">
<button class="pill" [class.active]="period === '1h'" (click)="setPeriod('1h')">1H</button>
<button class="pill" [class.active]="period === '6h'" (click)="setPeriod('6h')">6H</button>
<button class="pill" [class.active]="period === '24h'" (click)="setPeriod('24h')">24H</button>
<button class="pill" [class.active]="period === '7d'" (click)="setPeriod('7d')">7D</button>
</div>
</div>
<div *ngIf="loading" class="loading-state" transloco="common.loading"></div>
<div *ngIf="error" class="error-state" transloco="admin.monitoring.error"></div>
<ng-container *ngIf="!loading && !error && data">
<!-- Core Web Vitals -->
<div class="kpi-row">
<div class="neu-card kpi-card">
<div class="kpi-label">LCP (ms)</div>
<div class="kpi-value" [class]="lcpStatus(webVitals.lcp)">{{ webVitals.lcp | number:'1.0-0' }}</div>
<div class="kpi-sublabel">Largest Contentful Paint</div>
</div>
<div class="neu-card kpi-card">
<div class="kpi-label">FID (ms)</div>
<div class="kpi-value" [class]="fidStatus(webVitals.fid)">{{ webVitals.fid | number:'1.0-0' }}</div>
<div class="kpi-sublabel">First Input Delay</div>
</div>
<div class="neu-card kpi-card">
<div class="kpi-label">CLS</div>
<div class="kpi-value" [class]="clsStatus(webVitals.cls)">{{ webVitals.cls | number:'1.3-3' }}</div>
<div class="kpi-sublabel">Cumulative Layout Shift</div>
</div>
</div>
<!-- Throughput -->
<div class="neu-card chart-card">
<h3 class="card-title" transloco="admin.monitoring.throughput"></h3>
<canvas #throughputCanvas></canvas>
</div>
<!-- Error Rate + Apdex -->
<div class="two-col">
<div class="neu-card chart-card">
<h3 class="card-title" transloco="admin.monitoring.error_rate"></h3>
<canvas #errorCanvas></canvas>
</div>
<div class="neu-card chart-card">
<h3 class="card-title" transloco="admin.monitoring.apdex"></h3>
<canvas #apdexCanvas></canvas>
</div>
</div>
<!-- Response Time -->
<div class="neu-card chart-card">
<h3 class="card-title" transloco="admin.monitoring.response_time"></h3>
<canvas #responseCanvas></canvas>
</div>
</ng-container>
</div>
- Step 3: Create NewRelicDashboardComponent SCSS
// src/app/admin/newrelic-dashboard.component.scss
.page-container { padding: 2rem; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
.page-title { font-size: 1.5rem; font-weight: 700; color: var(--neu-text); }
.period-pills { display: flex; gap: .5rem; }
.pill {
padding: .35rem .85rem; border-radius: var(--neu-r-pill); border: none; cursor: pointer;
background: var(--neu-bg); box-shadow: var(--neu-raised-sm); color: var(--neu-text); font-size: .82rem;
&.active { box-shadow: var(--neu-inset-sm); color: var(--neu-accent); }
}
.kpi-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
.kpi-card { padding: 1.25rem 1.5rem; }
.kpi-label { font-size: .78rem; color: #8f9bb3; text-transform: uppercase; letter-spacing: .06em; margin-bottom: .5rem; }
.kpi-sublabel { font-size: .72rem; color: #8f9bb3; margin-top: .25rem; }
.kpi-value {
font-size: 2rem; font-weight: 700; color: var(--neu-text);
&.good { color: var(--neu-success); }
&.needs-improvement { color: #f59e0b; }
&.poor { color: var(--neu-danger); }
}
.chart-card { padding: 1.25rem; margin-bottom: 1.5rem; }
.card-title { font-size: 1rem; font-weight: 600; color: var(--neu-text); margin: 0 0 1rem; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem; }
.loading-state, .error-state { padding: 3rem; text-align: center; color: #8f9bb3; font-size: 1rem; }
.error-state { color: var(--neu-danger); }
- Step 4: Add route to app.routes.ts
Add this import:
import { NewRelicDashboardComponent } from './admin/newrelic-dashboard.component';
Add this route inside the MenuComponent children array (alongside the other admin routes):
{ path: 'admin/monitoring', component: NewRelicDashboardComponent, canActivate: [adminGuard], data: { title: 'menu.monitoring.newrelic' } },
- Step 5: Add MONITORING menu item to menu.component.ts
Add a new monitoring section in allMenuItems (separate from the admin security section):
{
label: 'menu.monitoring.title',
id: 'monitoring',
expanded: false,
children: [
{ label: 'menu.monitoring.newrelic', id: 'monitoring.newrelic', url: '/admin/monitoring' },
],
},
- Step 6: Add i18n strings
In en.json, add to the menu object:
"monitoring": {
"title": "Monitoring",
"newrelic": "New Relic"
}
Add a new "admin.monitoring" section under "admin":
"monitoring": {
"throughput": "Throughput (req/min)",
"error_rate": "Error Rate (%)",
"apdex": "Apdex Score",
"response_time": "Response Time (ms) — P50 / P90 / P99",
"error": "Failed to load monitoring data. Check NewRelic configuration."
}
In pt-BR.json, add the same structure:
"monitoring": {
"title": "Monitoramento",
"newrelic": "New Relic"
}
"monitoring": {
"throughput": "Throughput (req/min)",
"error_rate": "Taxa de Erro (%)",
"apdex": "Pontuação Apdex",
"response_time": "Tempo de Resposta (ms) — P50 / P90 / P99",
"error": "Falha ao carregar dados de monitoramento. Verifique a configuração do NewRelic."
}
Also add "loading": "Loading..." to "common" in both files if not already present.
- Step 7: Build to verify
npm run build -- --configuration development 2>&1 | tail -20
Expected: Build succeeds.
- Step 8: Commit
git add src/app/admin/newrelic-dashboard.component.* src/app/app.routes.ts src/app/menu/menu.component.ts src/assets/i18n/
git commit -m "feat(fe): add NewRelicDashboardComponent with Chart.js charts"
Self-Review Checklist
-
Spec coverage: ✅ One NR account, one license key (GCP Secret Manager
xtrakt-json-newrelic) ✅ APM agent config for all 5 .NET backend services (Dockerfile + env vars) ✅ Browser agent snippet inindex.html✅ NerdGraph proxy atGET /monitoring/dashboard?period=in xtrakt-platform-be ✅ NR API key stays server-side only (never sent to browser) ✅ Period selector: 1H / 6H / 24H / 7D ✅ Charts: Throughput, Error Rate, Apdex, Response Time P50/P90/P99, Web Vitals (LCP/FID/CLS) ✅ Dashboard is admin-only (usesadminGuardfrom Plan 2) ✅ MONITORING section in sidebar separate from SECURITY section -
Plan 2 fix noted:
AdminServiceEnvironmentServicereference corrected at top of this plan. -
Type consistency:
ThroughputPoint,ResponseTimeStats,WebVitalsmatch betweenGetDashboardDataResponse.cs(server) and the Angular component's interfaces. -
NerdGraph auth: The User API Key (
Api-Keyheader) is different from the License Key (NEW_RELIC_LICENSE_KEY). The license key is used by the APM/Browser agents; the User API Key is used for NerdGraph queries. Both are stored inxtrakt-json-newrelicsecret. -
CORS: The platform-be must allow the Angular origin for
/monitoring/dashboard. CheckWeb/Startup.csCORS policy includes the deployed Angular URL.