Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: analytics and api deployment-specific runtime configuration #2412

Merged
merged 6 commits into from
Sep 16, 2024
Merged
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
23 changes: 19 additions & 4 deletions packages/data-models/deployment.model.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { IGdriveEntry } from "../@idemsInternational/gdrive-tools";
import type { IAppConfig } from "./appConfig";
import type { IAppConfigOverride } from "./appConfig";

/** Update version to force recompile next time deployment set (e.g. after default config update) */
export const DEPLOYMENT_CONFIG_VERSION = 20240910.0;
export const DEPLOYMENT_CONFIG_VERSION = 20240912.0;

/** Configuration settings available to runtime application */
export interface IDeploymentRuntimeConfig {
Expand All @@ -12,6 +12,8 @@ export interface IDeploymentRuntimeConfig {
_content_version: string;

api: {
/** Specify whether to enable communication with backend API (default true)*/
enabled: boolean;
/** Name of target db for api operations. Default `plh` */
db_name?: string;
/**
Expand All @@ -20,8 +22,14 @@ export interface IDeploymentRuntimeConfig {
* */
endpoint?: string;
};
analytics: {
enabled: boolean;
provider: "matomo";
endpoint: string;
siteId: number;
};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new deployment config available (previously hardcoded into environment)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good, shall we add some example values to the debug deployment config, if you haven't done so already?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I've just added a new siteId for storing debug analytics and will make a separate PR on the content repo to update.

/** Optional override of any provided constants from data-models/constants */
app_config: IAppConfig;
app_config: IAppConfigOverride;
/** 3rd party integration for logging services */
error_logging?: {
/** sentry/glitchtip logging dsn */
Expand Down Expand Up @@ -156,10 +164,17 @@ export const DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS: IDeploymentRuntimeConfig = {
_app_builder_version: "",
name: "",
api: {
enabled: true,
db_name: "plh",
endpoint: "https://apps-server.idems.international/api",
},
app_config: {} as any, // populated by `getDefaultAppConstants()`,
analytics: {
enabled: true,
provider: "matomo",
siteId: 1,
endpoint: "https://apps-server.idems.international/analytics",
},
app_config: {},

firebase: {
config: null,
Expand Down
13 changes: 8 additions & 5 deletions packages/scripts/src/tasks/providers/appData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,20 @@ const copyDeploymentDataToApp = () => {
};

function generateRuntimeConfig(deploymentConfig: IDeploymentConfigJson): IDeploymentRuntimeConfig {
const { api, app_config, firebase, supabase, error_logging, git, name } = deploymentConfig;
const { analytics, api, app_config, error_logging, firebase, git, name, supabase, web } =
deploymentConfig;

return {
_app_builder_version: packageJSON.version,
_content_version: git.content_tag_latest || "",
analytics,
api,
app_config,
firebase,
supabase,
error_logging,
_app_builder_version: packageJSON.version,
_content_version: git.content_tag_latest || "",
firebase,
name,
supabase,
web,
};
}

Expand Down
29 changes: 8 additions & 21 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { APP_INITIALIZER, ErrorHandler, NgModule } from "@angular/core";
import { ErrorHandler, NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { RouteReuseStrategy } from "@angular/router";
import { HttpClientModule } from "@angular/common/http";
import { HTTP_INTERCEPTORS, HttpClientModule } from "@angular/common/http";
import { IonicModule, IonicRouteStrategy } from "@ionic/angular";

// Libs
import { LottieModule } from "ngx-lottie";
import player from "lottie-web";
import { MatomoModule, MatomoRouterModule } from "ngx-matomo-client";

// Native
import { HTTP } from "@ionic-native/http/ngx";
Expand All @@ -19,13 +18,12 @@ import { Device } from "@ionic-native/device/ngx";
import { AppComponent } from "./app.component";
import { AppRoutingModule } from "./app-routing.module";
import { SharedModule } from "./shared/shared.module";
import { environment } from "src/environments/environment";
import { httpInterceptorProviders } from "./shared/services/server/interceptors";
import { TemplateComponentsModule } from "./shared/components/template/template.module";
import { ContextMenuModule } from "./shared/modules/context-menu/context-menu.module";
import { TourModule } from "./feature/tour/tour.module";
import { ErrorHandlerService } from "./shared/services/error-handler/error-handler.service";
import { DeploymentService } from "./shared/services/deployment/deployment.service";
import { ServerAPIInterceptor } from "./shared/services/server/interceptors";
import { DeploymentFeaturesModule } from "./deployment-features.module";

// Note we need a separate function as it's required
// by the AOT compiler.
Expand All @@ -48,27 +46,16 @@ export function lottiePlayerFactory() {
// LottieCacheModule.forRoot(),
TemplateComponentsModule,
TourModule,
MatomoModule.forRoot({
siteId: environment.analytics.siteId,
trackerUrl: environment.analytics.endpoint,
}),
MatomoRouterModule,
ContextMenuModule,
DeploymentFeaturesModule,
],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
HTTP,
Device,
// ensure deployment service initialized before app component load
{
provide: APP_INITIALIZER,
multi: true,
useFactory: (deploymentService: DeploymentService) => {
return () => deploymentService.ready();
},
deps: [DeploymentService],
},
httpInterceptorProviders,
// Use custom api interceptor to handle interaction with server backend
{ provide: HTTP_INTERCEPTORS, useClass: ServerAPIInterceptor, multi: true },
// Use custom error handler
{ provide: ErrorHandler, useClass: ErrorHandlerService },
],
bootstrap: [AppComponent],
Expand Down
17 changes: 17 additions & 0 deletions src/app/deployment-features.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NgModule } from "@angular/core";

import { AnalyticsModule } from "./shared/services/analytics";

/**
* Module imports required for specific deployment features
*
* NOTE - as angular needs all modules to be statically defined during compilation
* it is not possible to conditionally load modules at runtime.
*
* Therefore all modules are defined and loaded as part of the core build process,
* but it is still possible to override this file to create specific feature-optimised builds
*
* This is a feature marked for future implementation
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I can see how listing the child modules here could work

*/
@NgModule({ imports: [AnalyticsModule] })
export class DeploymentFeaturesModule {}
45 changes: 45 additions & 0 deletions src/app/shared/services/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NgModule } from "@angular/core";

import {
MATOMO_CONFIGURATION,
MatomoConfiguration,
provideMatomo,
withRouter,
} from "ngx-matomo-client";

import { IDeploymentRuntimeConfig } from "packages/data-models";
import { DEPLOYMENT_CONFIG } from "../deployment/deployment.service";
import { environment } from "src/environments/environment";

/** When running locally can configure to target local running containing (if required) */
const devConfig: MatomoConfiguration = {
disabled: true,
trackerUrl: "http://localhost/analytics",
siteId: 1,
};

/**
* When configuring the analytics module
* This should be imported into the main app.module.ts
*/
@NgModule({
imports: [],
providers: [
provideMatomo(null, withRouter()),
// Dynamically provide the configuration used by the matomo provider so that it can
// access deployment config (injected from token)
{
provide: MATOMO_CONFIGURATION,
useFactory: (deploymentConfig: IDeploymentRuntimeConfig): MatomoConfiguration => {
if (environment.production) {
const { enabled, endpoint, siteId } = deploymentConfig.analytics;
return { disabled: !enabled, siteId, trackerUrl: endpoint };
} else {
return devConfig;
}
},
deps: [DEPLOYMENT_CONFIG],
},
],
})
export class AnalyticsModule {}
2 changes: 2 additions & 0 deletions src/app/shared/services/analytics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./analytics.module";
export * from "./analytics.service";
66 changes: 25 additions & 41 deletions src/app/shared/services/deployment/deployment.service.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,33 @@
import { Injectable } from "@angular/core";
import { AsyncServiceBase } from "../asyncService.base";
import { HttpClient } from "@angular/common/http";
import { catchError, map, of, firstValueFrom } from "rxjs";
import { DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS, IDeploymentRuntimeConfig } from "packages/data-models";
import { Inject, Injectable, InjectionToken } from "@angular/core";
import { IDeploymentRuntimeConfig } from "packages/data-models";
import { SyncServiceBase } from "../syncService.base";

@Injectable({ providedIn: "root" })
/**
* Deployment runtime config settings
* Token to inject deployment config value into any service.
* This is populated from json file before platform load, as part of src\main.ts
*
* NOTE - this is intialized using an `APP_INITIALIZER` token within
* the main app.module.ts and will block all other services from loading until
* it is fully initialised
* Can be used directly by any service or module initialised at any time
* (including app.module.ts).
*
* Services that access the deployment config therefore do not need to await
* DeploymentService init/ready methods.
* @example Inject into service
* ```ts
* constructor(@Inject(DEPLOYMENT_CONFIG))
* ```
* @example Inject into module
* ```
* {provide: MyModule, useFactory:(config)=>{...}, deps: [DEPLOYMENT_CONFIG]`}
* ```
*/
export class DeploymentService extends AsyncServiceBase {
constructor(private http: HttpClient) {
super("Deployment Service");
this.registerInitFunction(this.initialise);
}

/** Private writeable config to allow population from JSON */
private _config = DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS;

/** Read-only access to deployment runtime config */
public get config() {
return this._config;
}
export const DEPLOYMENT_CONFIG: InjectionToken<IDeploymentRuntimeConfig> =
new InjectionToken<IDeploymentRuntimeConfig>("Application Configuration");

/** Load active deployment configuration from JSON file */
private async initialise() {
const deployment = await firstValueFrom(this.loadDeployment());
if (deployment) {
this._config = deployment;
}
}

private loadDeployment() {
return this.http.get("assets/app_data/deployment.json").pipe(
catchError(() => {
console.warn("No deployment config available");
return of(null);
}),
map((v) => v as IDeploymentRuntimeConfig)
);
/**
* The deployment service provides access to values loaded from the deployment json file
* It is an alternative to injecting directly via `@Inject(DEPLOYMENT_CONFIG)`
*/
@Injectable({ providedIn: "root" })
export class DeploymentService extends SyncServiceBase {
constructor(@Inject(DEPLOYMENT_CONFIG) public readonly config: IDeploymentRuntimeConfig) {
super("Deployment Service");
}
}
38 changes: 20 additions & 18 deletions src/app/shared/services/server/interceptors.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,38 @@
import { Injectable } from "@angular/core";
import { Inject, Injectable } from "@angular/core";
import {
HttpEvent,
HttpInterceptor,
HttpHandler,
HttpRequest,
HTTP_INTERCEPTORS,
HttpHeaders,
} from "@angular/common/http";
import { environment } from "src/environments/environment";
import { Observable } from "rxjs";

let { db_name, endpoint: API_ENDPOINT } = environment.deploymentConfig.api;

// Override development credentials when running locally
if (!environment.production) {
// Docker endpoint. Replace :3000 with /api if running standalone api
API_ENDPOINT = "http://localhost:3000";
db_name = "dev";
}
import { DEPLOYMENT_CONFIG } from "../deployment/deployment.service";
import { IDeploymentRuntimeConfig } from "packages/data-models";

/** Handle updating urls intended for api server */
@Injectable()
export class ServerAPIInterceptor implements HttpInterceptor {
// Inject the global deployment config to use with requests
constructor(@Inject(DEPLOYMENT_CONFIG) private deploymentConfig: IDeploymentRuntimeConfig) {}

/**
* Intercept all http requests to rewrite including database api endpoint and
* deployment-db-name headers, as read from deployment config
*/
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// assume requests targetting / (e.g. /app_users) is directed to api endpoint
if (req.url.startsWith("/")) {
const replacedUrl = `${API_ENDPOINT}${req.url}`;
const { db_name, endpoint, enabled } = this.deploymentConfig.api;
// If not using api silently cancel any requests to the api
// TODO - better to disable in service (could also replace interceptor with service more generally)
if (!enabled) return;
if (!db_name || !endpoint) {
console.warn("api endpoint not configured, ignoring request", req.url);
return;
}

const replacedUrl = `${endpoint}${req.url}`;
// append deployment-specific values (header set/append methods inconsistent so create new)
const headerValues = { "x-deployment-db-name": db_name };
for (const key of req.headers.keys()) {
Expand All @@ -37,8 +44,3 @@ export class ServerAPIInterceptor implements HttpInterceptor {
return next.handle(req);
}
}

/** Http interceptor providers in outside-in order */
export const httpInterceptorProviders = [
{ provide: HTTP_INTERCEPTORS, useClass: ServerAPIInterceptor, multi: true },
];
1 change: 0 additions & 1 deletion src/environments/environment.prod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ export const environment = {
domains: ["plh-demo1.idems.international", "plh-demo.idems.international"],
chatNonNavigatePaths: ["/chat/action", "/chat/msg-info"],
variableNameFlows: ["character_names"],
analytics: { endpoint: "https://apps-server.idems.international/analytics", siteId: 1 },
};
4 changes: 0 additions & 4 deletions src/environments/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,6 @@ export const environment = {
domains: ["plh-demo1.idems.international", "plh-demo.idems.international"],
chatNonNavigatePaths: ["/chat/action", "/chat/msg-info"],
variableNameFlows: ["character_names"],
/** Local Settings */
analytics: { endpoint: "http://localhost/analytics", siteId: 1 },
/** Production Settings **/
// analytics: { endpoint: "https://apps-server.idems.international/analytics", siteId: 1 },
};

// This file can be replaced during build by using the `fileReplacements` array.
Expand Down
Loading
Loading