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

Karlie/web snippet #891

Merged
merged 19 commits into from
Mar 30, 2022
Merged
Show file tree
Hide file tree
Changes from 10 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
147 changes: 147 additions & 0 deletions AutoCollection/WebSnippet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import * as path from "path";
import http = require("http");
import fs = require('fs');

import Logging = require("../Library/Logging");
import TelemetryClient = require("../Library/TelemetryClient");

class WebSnippet {

public static INSTANCE: WebSnippet;

private static _snippet: string;
private static _aiUrl: string;
private _isEnabled: boolean;
private _isInitialized: boolean;

constructor(client: TelemetryClient) {
if (!!WebSnippet.INSTANCE) {
throw new Error("Web snippet injection should be configured from the applicationInsights object");
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
}

WebSnippet.INSTANCE = this;
// AI URL used to validate if snippet already included
WebSnippet._aiUrl = " https://js.monitor.azure.com/scripts/b/ai.2";
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved

let snippetPath = path.resolve(__dirname, "../../node_modules/applicationinsight-web-snippet/snippet/snippet.min.js");
if (client.config.isDebugWebSnippet) {
snippetPath = path.resolve(__dirname, "../../node_modules/applicationinsight-web-snippet/snippet/snippet.js");
}
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
fs.readFile(snippetPath, function (err, snippet) {
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
if (err) {
Logging.warn("Failed to load AI Web snippet. Ex:" + err);
}
WebSnippet._snippet = snippet.toString().replace("INSTRUMENTATION_KEY", client.config.instrumentationKey);
});
}

public enable(isEnabled: boolean) {
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
this._isEnabled = isEnabled;

if (this._isEnabled && !this._isInitialized) {
this._initialize();
}
}

public isInitialized() {
return this._isInitialized;
}

private _initialize() {
this._isInitialized = true;
ramthi marked this conversation as resolved.
Show resolved Hide resolved
const originalHttpServer = http.createServer;
http.createServer = (requestListener?: (request: http.IncomingMessage, response: http.ServerResponse) => void) => {
const originalRequestListener = requestListener;
if (originalRequestListener) {
requestListener = (request: http.IncomingMessage, response: http.ServerResponse) => {
// Patch response write method
let originalResponseWrite = response.write;
let isEnabled = this._isEnabled;
response.write = function wrap(a: Buffer | string, b?: Function | string, c?: Function | string) {
if (isEnabled) {
if (WebSnippet.ValidateInjection(response, a)) {
arguments[0] = WebSnippet.InjectWebSnippet(response, a);
}
}
return originalResponseWrite.apply(response, arguments);
}
// Patch response end method for cases when HTML is added there
let originalResponseEnd = response.end;

response.end = function wrap(a?: Buffer | string | any, b?: Function | string, c?: Function) {
if (isEnabled) {
if (WebSnippet.ValidateInjection(response, a)) {
arguments[0] = WebSnippet.InjectWebSnippet(response, a);
}
}
originalResponseEnd.apply(response, arguments);
}

originalRequestListener(request, response);
}
}
return originalHttpServer(requestListener);
}
}

/**
* Validate response and try to inject Web snippet
*/
public static ValidateInjection(response: http.ServerResponse, input: string | Buffer): boolean {
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
if (response && input) {
// insert snippet if only response returns 200
if (response.statusCode != 200) return false;
let contentType = response.getHeader('Content-Type');
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
let contentEncoding = response.getHeader('Content-Encoding'); // Compressed content not supported for injection
if (!contentEncoding && contentType && typeof contentType == "string" && contentType.toLowerCase().indexOf("text/html") >= 0) {
let html = input.toString();
if (html.indexOf("<head>") >= 0 && html.indexOf("</head>") >= 0) {
// Check if snippet not already present looking for AI Web SDK URL
if (html.indexOf(WebSnippet._aiUrl) < 0) {
return true;
}
}
}
}
return false;
}

/**
* Inject Web snippet
*/
public static InjectWebSnippet(response: http.ServerResponse, input: string | Buffer): string | Buffer {
try {
let hasContentHeader = !!response.getHeader('Content-Length');
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
// Clean content-length header
if (hasContentHeader) {
response.removeHeader('Content-Length');
}
// Read response stream
let html = input.toString();
// Try to add script before HTML head closure
let index = html.indexOf("</head>");
if (index >= 0) {
let subStart = html.substring(0, index);
let subEnd = html.substring(index);
input = subStart + '<script type="text/javascript">' + WebSnippet._snippet + '</script>' + subEnd;
html = subStart + '<script type="text/javascript">' + WebSnippet._snippet + '</script>' + subEnd;
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
// Set headers
if (hasContentHeader) {
response.setHeader("Content-Length", input.length.toString());
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
catch (ex) {
Logging.info("Failed to change content-lenght headers for JS injection. Exception:" + ex);
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
}
return input;
}

public dispose() {
WebSnippet.INSTANCE = null;
this.enable(false);
this._isInitialized = false;
}
}

export = WebSnippet;
26 changes: 26 additions & 0 deletions AutoCollection/WebSnippet/fetchSnippet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const path = require('path');
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
const fs = require('fs');
const https = require('https');

let snippetUrl = 'https://raw.githubusercontent.com/microsoft/ApplicationInsights-JS/master/AISKU/snippet/snippet.js';
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
let snippetMinUrl = 'https://raw.githubusercontent.com/microsoft/ApplicationInsights-JS/master/AISKU/snippet/snippet.min.js';

let snippetPath = path.resolve(__dirname, "snippet.js");
var snippet = fs.createWriteStream(snippetPath);
https.get(snippetUrl, function (res) {
res.on('data', function (data) {
snippet.write(data);
}).on('end', function () {
snippet.end();
});
});

let minSnippetPath = path.resolve(__dirname, "snippet.min.js");
var minSnippet = fs.createWriteStream(minSnippetPath);
https.get(snippetMinUrl, function (res) {
res.on('data', function (data) {
minSnippet.write(data);
}).on('end', function () {
minSnippet.end();
});
});
6 changes: 6 additions & 0 deletions Declarations/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ export interface IBaseConfig {
* Live Metrics custom host
*/
quickPulseHost: string;
/**
* Enable web snippet auto html injection, default to false
*/
enableAutoWebSnippetInjection: boolean;
isDebugWebSnippet: boolean;
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved

}

export interface IEnvironmentConfig {
Expand Down
8 changes: 8 additions & 0 deletions Library/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,17 @@ class Config implements IConfig {
public disableStatsbeat: boolean;
public extendedMetricDisablers: string;
public quickPulseHost: string;
public enableAutoWebSnippetInjection: boolean;
public isDebugWebSnippet: boolean;

public correlationId: string; // TODO: Should be private
private _connectionString: string;
private _endpointBase: string = Constants.DEFAULT_BREEZE_ENDPOINT;
private _setCorrelationId: (v: string) => void;
private _profileQueryEndpoint: string;
private _instrumentationKey: string;
//private _isSnippetInjection: string;
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved



constructor(setupString?: string) {
Expand All @@ -82,6 +86,8 @@ class Config implements IConfig {
this.disableAppInsights = this.disableAppInsights || false;
this.samplingPercentage = this.samplingPercentage || 100;
this.correlationIdRetryIntervalMs = this.correlationIdRetryIntervalMs || 30 * 1000;
this.enableAutoWebSnippetInjection = this.enableAutoWebSnippetInjection || false;
this.isDebugWebSnippet = this.isDebugWebSnippet || false;
this.correlationHeaderExcludedDomains =
this.correlationHeaderExcludedDomains ||
[
Expand Down Expand Up @@ -160,6 +166,8 @@ class Config implements IConfig {
this.proxyHttpsUrl = jsonConfig.proxyHttpsUrl;
this.quickPulseHost = jsonConfig.quickPulseHost;
this.samplingPercentage = jsonConfig.samplingPercentage;
this.enableAutoWebSnippetInjection = jsonConfig.enableAutoWebSnippetInjection;
this.isDebugWebSnippet = jsonConfig.isDebugWebSnippet;
}

private static _getInstrumentationKey(): string {
Expand Down
10 changes: 10 additions & 0 deletions Library/JsonConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const ENV_noDiagnosticChannel = "APPLICATION_INSIGHTS_NO_DIAGNOSTIC_CHANNEL"
const ENV_noStatsbeat = "APPLICATION_INSIGHTS_NO_STATSBEAT";
const ENV_noHttpAgentKeepAlive = "APPLICATION_INSIGHTS_NO_HTTP_AGENT_KEEP_ALIVE";
const ENV_noPatchModules = "APPLICATION_INSIGHTS_NO_PATCH_MODULES";
const ENV_webSnippetEnable = "APPINSIGHTS_WEB_SNIPPET_ENABLED";
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved

export class JsonConfig implements IJsonConfig {
private static _instance: JsonConfig;
Expand Down Expand Up @@ -59,6 +60,8 @@ export class JsonConfig implements IJsonConfig {
public noPatchModules: string;
public noHttpAgentKeepAlive: boolean;
public quickPulseHost: string;
public enableAutoWebSnippetInjection: boolean;
public isDebugWebSnippet: boolean;


static getInstance() {
Expand All @@ -79,6 +82,7 @@ export class JsonConfig implements IJsonConfig {
this.disableStatsbeat = !!process.env[ENV_noStatsbeat];
this.noHttpAgentKeepAlive = !!process.env[ENV_noHttpAgentKeepAlive];
this.noPatchModules = process.env[ENV_noPatchModules] || "";
this.enableAutoWebSnippetInjection = !!process.env[ENV_webSnippetEnable];
this._loadJsonFile();
}

Expand Down Expand Up @@ -130,6 +134,12 @@ export class JsonConfig implements IJsonConfig {
if (jsonConfig.noPatchModules != undefined) {
this.noPatchModules = jsonConfig.noPatchModules;
}
if (jsonConfig.enableAutoWebSnippetInjection != undefined) {
this.enableAutoWebSnippetInjection = jsonConfig.enableAutoWebSnippetInjection;
}
if (jsonConfig.isDebugWebSnippet !== undefined) {
Karlie-777 marked this conversation as resolved.
Show resolved Hide resolved
this.isDebugWebSnippet = jsonConfig.isDebugWebSnippet;
}
this.endpointUrl = jsonConfig.endpointUrl;
this.maxBatchSize = jsonConfig.maxBatchSize;
this.maxBatchIntervalMs = jsonConfig.maxBatchIntervalMs;
Expand Down
94 changes: 94 additions & 0 deletions Tests/AutoCollection/WebSnippet.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import assert = require("assert");
import http = require("http");
import sinon = require("sinon");

import AppInsights = require("../../applicationinsights");
import WebSnippet = require("../../AutoCollection/WebSnippet");


describe("AutoCollection/WebSnippet", () => {
afterEach(() => {
AppInsights.dispose();
});

describe("#init and #dispose()", () => {
it("init should enable and dispose should stop injection", () => {
var appInsights = AppInsights.setup("1aa11111-bbbb-1ccc-8ddd-eeeeffff3333").setWebSnippetInjection(true);
var enableWebSnippetsSpy = sinon.spy(WebSnippet.INSTANCE, "enable");
appInsights.start();

assert.equal(enableWebSnippetsSpy.callCount, 1, "enable should be called once as part of autocollection initialization");
assert.equal(enableWebSnippetsSpy.getCall(0).args[0], true);
AppInsights.dispose();
assert.equal(enableWebSnippetsSpy.callCount, 2, "enable(false) should be called once as part of autocollection shutdown");
assert.equal(enableWebSnippetsSpy.getCall(1).args[0], false);
});
});

describe("#validate response", () => {
it("injection should be triggered only in HTML responses", () => {
let _headers: any = {};
let response: http.ServerResponse = <any>{
setHeader: (header: string, value: string) => {
_headers[header] = value;
},
getHeader: (header: string) => { return _headers[header]; }
};
response.statusCode = 300;
let validHtml = "<html><head></head><body></body></html>";
assert.equal(WebSnippet.ValidateInjection(response, validHtml), false); // status code is not 200
response.statusCode = 200;
assert.equal(WebSnippet.ValidateInjection(response, validHtml), false); // No html header
response.setHeader("Content-Type", "text/html");
assert.equal(WebSnippet.ValidateInjection(response, validHtml), true); // Valid
assert.equal(WebSnippet.ValidateInjection(response, "test"), false); // No html text
assert.equal(WebSnippet.ValidateInjection(response, "<html><body></body></html>"), false); // No head element
response.setHeader("Content-Type", "text/plain");
assert.equal(WebSnippet.ValidateInjection(response, validHtml), false); // No HTML content type
response.setHeader("Content-Type", "text/html");
response.setHeader("Content-Encoding", "gzip");
assert.equal(WebSnippet.ValidateInjection(response, validHtml), false); // Encoding not supported
});
});

describe("#web snippet injection", () => {
it("injection add correct snippet code", () => {
let _headers: any = {};
let response: http.ServerResponse = <any>{
setHeader: (header: string, value: string) => {
_headers[header] = value;
},
getHeader: (header: string) => { return _headers[header]; },
removeHeader: (header: string) => { _headers[header] = undefined; }
};
response.setHeader("Content-Type", "text/html");
response.statusCode = 200;
let validHtml = "<html><head></head><body></body></html>";
assert.equal(WebSnippet.ValidateInjection(response, validHtml), true); // Encoding not supported
let newHtml = WebSnippet.InjectWebSnippet(response, validHtml).toString();
assert.ok(newHtml.indexOf("https://js.monitor.azure.com/scripts/b/ai.2.min.js") >= 0);
assert.ok(newHtml.indexOf("<html><head>") == 0);
assert.ok(newHtml.indexOf('instrumentationKey: "1aa11111-bbbb-1ccc-8ddd-eeeeffff3333"') >= 0);
//should not overwrite content-lenghth header when original reponse does not have it.
assert.equal(!!response.getHeader("Content-Length"), false);
});

it("injection web snippet should overwrite content length ", () => {
let _headers: any = {};
let response: http.ServerResponse = <any>{
setHeader: (header: string, value: string) => {
_headers[header] = value;
},
getHeader: (header: string) => { return _headers[header]; },
removeHeader: (header: string) => { _headers[header] = undefined; }
};
response.setHeader("Content-Type", "text/html");
response.setHeader("Content-Length", 39);
let validHtml = "<html><head></head><body></body></html>";
let newHtml = WebSnippet.InjectWebSnippet(response, validHtml).toString();
assert.ok(newHtml.length > 4000);
assert.ok(Number(response.getHeader("Content-Length")) > 4000); // Content length updated
});
});

});
12 changes: 12 additions & 0 deletions Tests/Library/Config.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ describe("Library/Config", () => {
assert.equal(config.enableSendLiveMetrics, false);
assert.equal(config.extendedMetricDisablers, "gc,heap");
assert.equal(config.quickPulseHost, "testquickpulsehost.com");
assert.equal(config.enableAutoWebSnippetInjection, false);
assert.equal(config.isDebugWebSnippet, false);
});
});

Expand Down Expand Up @@ -142,6 +144,14 @@ describe("Library/Config", () => {
assert.equal(config.instrumentationKey, iKey);
});

it("should read enableWebSnippet from environment variables", () => {
var env = <{ [id: string]: string }>{};
env["APPINSIGHTS_WEB_SNIPPET_ENABLED"] = "true";
process.env = env;
var config = new Config("1aa11111-bbbb-1ccc-8ddd-eeeeffff3333");
assert.equal(config.enableAutoWebSnippetInjection, true);
});

it("should initialize valid values", () => {
var config = new Config("1aa11111-bbbb-1ccc-8ddd-eeeeffff3333");
assert(typeof config.instrumentationKey === "string");
Expand All @@ -164,6 +174,8 @@ describe("Library/Config", () => {
assert(config.correlationIdRetryIntervalMs === 30000);
assert(config.proxyHttpUrl === undefined);
assert(config.proxyHttpsUrl === undefined);
assert(config.enableAutoWebSnippetInjection === false);
assert(config.isDebugWebSnippet === false);

assert.equal(config.quickPulseHost, Constants.DEFAULT_LIVEMETRICS_HOST);
});
Expand Down
Loading