Skip to main content

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: AdminService in Plan 2 incorrectly references a non-existent EnvironmentService. In src/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 inject from the @angular/core import if it's no longer used elsewhere in the file.


File Map

Backend — xtrakt-platform-be

ActionPath
Createsrc/xTrakt.Platform.Application/Services/Interfaces/INewRelicService.cs
Createsrc/xTrakt.Platform.Application/Services/NewRelicService.cs
Modifysrc/xTrakt.Platform.Application/Startup.cs
Createsrc/xTrakt.Platform.Application/Handlers/Monitoring/GetDashboardDataHandler.cs
Createsrc/xTrakt.Platform.Application/Handlers/Monitoring/GetDashboardDataRequest.cs
Createsrc/xTrakt.Platform.Application/Handlers/Monitoring/GetDashboardDataResponse.cs
Createsrc/Web/Controllers/MonitoringController.cs

Each .NET backend service (xtrakt-sso-be, xtrakt-platform-be, xtrakt-billing-be, xtrakt-vision-be, xtrakt-api-be)

ActionPath
ModifyDockerfile (add NR agent layer)
Createnewrelic.config (agent config file)

Frontend — xtrakt-sso-fe/src

ActionPath
Modifysrc/index.html
Createsrc/app/admin/newrelic-dashboard.component.ts
Createsrc/app/admin/newrelic-dashboard.component.html
Createsrc/app/admin/newrelic-dashboard.component.scss
Modifysrc/app/app.routes.ts
Modifysrc/app/menu/menu.component.ts
Modifysrc/assets/i18n/en.json
Modifysrc/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-be
  • xtrakt-platform-be
  • xtrakt-billing-be
  • xtrakt-vision-be
  • xtrakt-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:

Serviceapp_name env var value
xtrakt-sso-bextrakt-sso-be
xtrakt-platform-bextrakt-platform-be
xtrakt-billing-bextrakt-billing-be
xtrakt-vision-bextrakt-vision-be
xtrakt-api-bextrakt-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:

  1. Go to Add dataBrowser
  2. App name: xtrakt-sso-fe
  3. Select Copy/paste JavaScript code method
  4. Copy the generated <script> block (it looks like window.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 in index.html ✅ NerdGraph proxy at GET /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 (uses adminGuard from Plan 2) ✅ MONITORING section in sidebar separate from SECURITY section

  • Plan 2 fix noted: AdminService EnvironmentService reference corrected at top of this plan.

  • Type consistency: ThroughputPoint, ResponseTimeStats, WebVitals match between GetDashboardDataResponse.cs (server) and the Angular component's interfaces.

  • NerdGraph auth: The User API Key (Api-Key header) 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 in xtrakt-json-newrelic secret.

  • CORS: The platform-be must allow the Angular origin for /monitoring/dashboard. Check Web/Startup.cs CORS policy includes the deployed Angular URL.