Skip to content
Draft
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
39 changes: 39 additions & 0 deletions plugin/codegen-resources/definitions/commonDefinitions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2889,9 +2889,17 @@
"type": "reAuthReason",
"required": false
},
{
"type": "reason",
"required": false
},
{
"type": "result"
},
{
"type": "sessionDuration",
"required": false
},
{
"type": "source"
},
Expand Down Expand Up @@ -7061,6 +7069,37 @@
],
"passive": true
},
{
"name": "toolkit_didLoadModule",
"description": "The module has loaded, i.e it has rendered/resolved/finished. You can use this metric by itself, OR after `toolkit_willOpenModule` + `traceId` to close the loop on an asynchronous operation.",
"metadata": [
{
"type": "attempts",
"required": false
},
{
"type": "duration",
"required": false
},
{
"type": "module",
"required": true
},
{
"type": "reason",
"required": false
},
{
"type": "result",
"required": true
},
{
"type": "version",
"required": false
}
],
"passive": true
},
{
"name": "toolkit_execute",
"description": "Emitted whenever a registered command is executed",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

package software.aws.toolkits.eclipse.amazonq.lsp.auth;

import java.time.Instant;
import java.util.Optional;

import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore;
import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.LoginIdcParams;
import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.LoginParams;
Expand Down Expand Up @@ -54,9 +57,49 @@ public String getSsoTokenId() {
return pluginStore.get(Constants.SSO_TOKEN_ID);
}

/**
* Persists the instant of the latest successful login alongside the start url it was performed
* against. It is used to report how long the previous authentication session lived for.
*
* @param startUrl the start url the login was performed against
* @param loginInstant the instant the login completed
*/
public void setLoginTimestamp(final String startUrl, final Instant loginInstant) {
if (startUrl == null || loginInstant == null) {
return;
}
pluginStore.put(Constants.LOGIN_TIMESTAMP_START_URL_KEY, startUrl);
pluginStore.put(Constants.LOGIN_TIMESTAMP_KEY, String.valueOf(loginInstant.toEpochMilli()));
}

/**
* Retrieves the instant of the latest successful login for the given start url. An empty value is
* returned when no login has been recorded yet, when the recorded login was performed against a
* different start url, or when the recorded value cannot be parsed.
*
* @param startUrl the start url the login is being performed against
* @return the instant of the previous successful login for that start url
*/
public Optional<Instant> getLoginTimestamp(final String startUrl) {
String storedStartUrl = pluginStore.get(Constants.LOGIN_TIMESTAMP_START_URL_KEY);
String storedTimestamp = pluginStore.get(Constants.LOGIN_TIMESTAMP_KEY);

if (startUrl == null || storedStartUrl == null || storedTimestamp == null || !storedStartUrl.equals(startUrl)) {
return Optional.empty();
}

try {
return Optional.of(Instant.ofEpochMilli(Long.parseLong(storedTimestamp)));
} catch (NumberFormatException ex) {
return Optional.empty();
}
}

public void clear() {
pluginStore.remove(Constants.LOGIN_TYPE_KEY);
pluginStore.remove(Constants.LOGIN_IDC_PARAMS_KEY);
pluginStore.remove(Constants.LOGIN_TIMESTAMP_START_URL_KEY);
pluginStore.remove(Constants.LOGIN_TIMESTAMP_KEY);
pluginStore.remove(Constants.SSO_TOKEN_ID);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.LoginParams;
import software.aws.toolkits.eclipse.amazonq.lsp.auth.model.LoginType;
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
import software.aws.toolkits.eclipse.amazonq.telemetry.AuthTelemetryProvider;
import software.aws.toolkits.eclipse.amazonq.util.AuthUtil;
import software.aws.toolkits.telemetry.TelemetryDefinitions.AuthStatus;

/**
* Manages authentication state transitions and persistence in the Amazon Q plugin.
Expand Down Expand Up @@ -43,6 +45,8 @@ public final class DefaultAuthStateManager implements AuthStateManager {
private String issuerUrl; // used in AmazonQLspClientImpl.getConnectionMetadata()
private String ssoTokenId; // used in logout's invalidateSsoToken params
private AuthState previousAuthState = null;
private boolean isRestoringPersistedAuthState = false;
private boolean hasEmittedStartupAuthState = false;

public DefaultAuthStateManager(final PluginStore pluginStore) {
this.authPluginStore = new AuthPluginStore(pluginStore);
Expand Down Expand Up @@ -132,6 +136,43 @@ private void updateState(final AuthStateType authStatusType, final LoginType log
}
}
previousAuthState = newAuthState;

emitStartupAuthStateMetric(newAuthState);
}

/**
* Reports the authentication state observed at startup, once per plugin session.
*
* The state restored from the plugin store is optimistic: a stored connection is assumed to still be
* valid until the re-authentication performed on start up resolves it. The optimistic state is
* therefore skipped and the metric is reported for the state that follows it, which is the outcome
* of that re-authentication. A restored logged out state needs no re-authentication and is
* definitive right away.
*
* @param authState the state the plugin transitioned to
* @see #syncAuthStateWithPluginStore()
* @see DefaultLoginService
*/
private void emitStartupAuthStateMetric(final AuthState authState) {
if (hasEmittedStartupAuthState || isRestoringPersistedAuthState) {
return;
}
hasEmittedStartupAuthState = true;

AuthTelemetryProvider.emitUserStateOnStartupMetric(toAuthStatus(authState.authStateType()), authState.issuerUrl());
}

private static AuthStatus toAuthStatus(final AuthStateType authStateType) {
switch (authStateType) {
case LOGGED_IN:
return AuthStatus.CONNECTED;
case EXPIRED:
return AuthStatus.EXPIRED;
case LOGGED_OUT:
return AuthStatus.NOT_CONNECTED;
default:
return AuthStatus.UNKNOWN;
}
}

private void syncAuthStateWithPluginStore() {
Expand Down Expand Up @@ -162,10 +203,18 @@ private void syncAuthStateWithPluginStore() {
*
* @see DefaultLoginService constructor that handles the re-authentication on LoginService start up
*/
boolean restoreFailed = false;
try {
isRestoringPersistedAuthState = true;
toLoggedIn(loginType, loginParams, ssoTokenId);
} catch (Exception ex) {
Activator.getLogger().error("Failed to transition to a logged in state after syncing auth state with the persistent store", ex);
restoreFailed = true;
} finally {
isRestoringPersistedAuthState = false;
}

if (restoreFailed) {
toLoggedOut();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@

package software.aws.toolkits.eclipse.amazonq.lsp.auth;

import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicReference;

import software.aws.toolkits.eclipse.amazonq.configuration.PluginStore;
Expand All @@ -18,7 +21,11 @@
import software.aws.toolkits.eclipse.amazonq.lsp.model.UpdateCredentialsPayload;
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
import software.aws.toolkits.eclipse.amazonq.providers.lsp.LspProvider;
import software.aws.toolkits.eclipse.amazonq.telemetry.AwsTelemetryProvider;
import software.aws.toolkits.eclipse.amazonq.telemetry.AwsTelemetryProvider.BrowserLoginParams;
import software.aws.toolkits.eclipse.amazonq.util.AuthUtil;
import software.aws.toolkits.telemetry.TelemetryDefinitions.CredentialType;
import software.aws.toolkits.telemetry.TelemetryDefinitions.Result;

/**
* Core authentication service for the Amazon Q Eclipse plugin that manages
Expand All @@ -41,11 +48,13 @@ public final class DefaultLoginService implements LoginService {
private AuthStateManager authStateManager;
private AuthTokenService authTokenService;
private AuthCredentialsService authCredentialsService;
private AuthPluginStore authPluginStore;

private DefaultLoginService(final Builder builder) {
this.authStateManager = Objects.requireNonNull(builder.authStateManager, "authStateManager cannot be null");
this.authTokenService = Objects.requireNonNull(builder.authTokenService, "authTokenService cannot be null");
this.authCredentialsService = Objects.requireNonNull(builder.authCredentialsService, "authCredentialsService cannot be null");
this.authPluginStore = new AuthPluginStore(Objects.requireNonNull(builder.pluginStore, "pluginStore cannot be null"));

if (builder.initializeOnStartUp) {
AuthState authState = authStateManager.getAuthState();
Expand Down Expand Up @@ -73,7 +82,7 @@ public CompletableFuture<Void> login(final LoginType loginType, final LoginParam

Activator.getLogger().info("Attempting to login...");

return processLogin(loginType, loginParams, true)
return processLogin(loginType, loginParams, true, false)
.exceptionally(throwable -> {
Activator.getLogger().error("Failed to log in", throwable);
logout();
Expand Down Expand Up @@ -141,7 +150,7 @@ public CompletableFuture<Void> reAuthenticate(final boolean loginOnInvalidToken)

Activator.getLogger().info("Attempting to re-authenticate...");

return processLogin(authState.loginType(), authState.loginParams(), loginOnInvalidToken)
return processLogin(authState.loginType(), authState.loginParams(), loginOnInvalidToken, true)
.exceptionally(throwable -> {
Activator.getLogger().error("Failed to re-authenticate", throwable);
logout();
Expand All @@ -154,7 +163,8 @@ public AuthState getAuthState() {
return authStateManager.getAuthState();
}

CompletableFuture<Void> processLogin(final LoginType loginType, final LoginParams loginParams, final boolean loginOnInvalidToken) {
CompletableFuture<Void> processLogin(final LoginType loginType, final LoginParams loginParams, final boolean loginOnInvalidToken,
final boolean isReAuth) {
AuthUtil.validateLoginParameters(loginType, loginParams);

final AtomicReference<String> ssoTokenId = new AtomicReference<>(); // Saved for logout
Expand All @@ -172,14 +182,75 @@ CompletableFuture<Void> processLogin(final LoginType loginType, final LoginParam
})
.thenRun(() -> {
authStateManager.toLoggedIn(loginType, loginParams, ssoTokenId.get());
if (loginOnInvalidToken) {
emitBrowserLoginMetric(loginType, loginParams, isReAuth, Result.SUCCEEDED, null);
}
Activator.getLogger().info("Successfully logged in");
})
/*
* Reports the outcome of the login itself. The steps that follow are not part of the login,
* so they are wired after this stage to keep them out of the metric.
*
* Only logins that were allowed to open the browser are reported. The re-authentication
* performed on start up passes loginOnInvalidToken=false, it refreshes the cached token
* silently and would otherwise report a browser login (and a session duration) on every
* start of the IDE.
*/
.whenComplete((unused, throwable) -> {
if (throwable != null && loginOnInvalidToken) {
emitBrowserLoginMetric(loginType, loginParams, isReAuth, Result.FAILED, getReasonCode(throwable));
}
}).thenRun(() -> {
CustomizationUtil.triggerChangeConfigurationNotification();
}).exceptionally(throwable -> {
throw new AmazonQPluginException("Failed to process log in", throwable);
});
}

/**
* Emits the browser login metric, reporting how long the previous authentication session for the
* same start url lived for.
*
* The session duration is only known once a login has been recorded for that start url, so it is
* left out of the first login and of the first login that follows a sign out, which clears the
* recorded login. A successful login becomes the new reference point for the next one.
*
* @param loginType the type of connection being authenticated
* @param loginParams the parameters of the connection being authenticated
* @param isReAuth whether the login renews an existing connection
* @param result whether the login succeeded
* @param reason a short reason code when the login failed, null otherwise
*/
private void emitBrowserLoginMetric(final LoginType loginType, final LoginParams loginParams, final boolean isReAuth,
final Result result, final String reason) {
String credentialStartUrl = AuthUtil.getIssuerUrl(loginType, loginParams);

// The start url identifies the authentication session, a metric without it carries no signal.
if (credentialStartUrl == null || credentialStartUrl.isBlank()) {
return;
}

Long sessionDuration = null;
if (result == Result.SUCCEEDED) {
Instant loginInstant = Instant.now();
sessionDuration = authPluginStore.getLoginTimestamp(credentialStartUrl)
.map(previousLogin -> Duration.between(previousLogin, loginInstant).toMillis())
.filter(duration -> duration >= 0) // guards against a recorded login dated in the future
.orElse(null);
authPluginStore.setLoginTimestamp(credentialStartUrl, loginInstant);
}

AwsTelemetryProvider.emitLoginWithBrowserEvent(new BrowserLoginParams(credentialStartUrl,
CredentialType.BEARER_TOKEN, isReAuth, result, reason, sessionDuration));
}

private static String getReasonCode(final Throwable throwable) {
Throwable cause = throwable instanceof CompletionException && throwable.getCause() != null
? throwable.getCause()
: throwable;
return cause.getClass().getSimpleName();
}

public static class Builder {
private LspProvider lspProvider;
private PluginStore pluginStore;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import software.aws.toolkits.eclipse.amazonq.broker.events.ToolkitLoginWebViewAssetState;
import software.aws.toolkits.eclipse.amazonq.plugin.Activator;
import software.aws.toolkits.eclipse.amazonq.telemetry.ToolkitTelemetryProvider;
import software.aws.toolkits.eclipse.amazonq.telemetry.UiTelemetryProvider;
import software.aws.toolkits.eclipse.amazonq.util.PluginUtils;
import software.aws.toolkits.eclipse.amazonq.util.ThemeDetector;
Expand All @@ -26,9 +27,13 @@
import software.aws.toolkits.eclipse.amazonq.views.ViewActionHandler;
import software.aws.toolkits.eclipse.amazonq.views.ViewCommandParser;
import software.aws.toolkits.eclipse.amazonq.views.ViewConstants;
import software.aws.toolkits.telemetry.TelemetryDefinitions.Result;

public final class ToolkitLoginWebViewAssetProvider extends WebViewAssetProvider {

private static final String DEPENDENCY_MISSING_REASON = "DependencyMissing";
private static final String ASSET_LOAD_FAILED_REASON = "AssetLoadFailed";

private WebviewAssetServer webviewAssetServer;
private static final ThemeDetector THEME_DETECTOR = new ThemeDetector();
private final ViewCommandParser commandParser;
Expand All @@ -47,6 +52,10 @@ public void initialize() {
if (content.isEmpty()) {
ThreadingUtils.executeAsyncTask(() -> {
content = resolveContent();
if (content.isEmpty()) {
ToolkitTelemetryProvider.emitDidLoadModuleEventMetric(ToolkitTelemetryProvider.LOGIN_MODULE,
Result.FAILED, DEPENDENCY_MISSING_REASON);
}
Activator.getEventBroker().post(ToolkitLoginWebViewAssetState.class,
content.isPresent() ? ToolkitLoginWebViewAssetState.RESOLVED
: ToolkitLoginWebViewAssetState.DEPENDENCY_MISSING);
Expand Down Expand Up @@ -93,6 +102,8 @@ private Optional<String> resolveContent() {
webviewAssetServer = new WebviewAssetServer();
var result = webviewAssetServer.resolve(jsDirectoryPath);
if (!result) {
ToolkitTelemetryProvider.emitDidLoadModuleEventMetric(ToolkitTelemetryProvider.LOGIN_MODULE,
Result.FAILED, ASSET_LOAD_FAILED_REASON);
return Optional.of("Failed to load JS");
}
var loginJsPath = webviewAssetServer.getUri() + "getStart.js";
Expand Down Expand Up @@ -146,6 +157,8 @@ private Optional<String> resolveContent() {
""",
loginJsPath, loginJsPath, loginJsPath, getWaitFunction(), isDarkTheme));
} catch (IOException e) {
ToolkitTelemetryProvider.emitDidLoadModuleEventMetric(ToolkitTelemetryProvider.LOGIN_MODULE,
Result.FAILED, DEPENDENCY_MISSING_REASON);
return Optional.of("Failed to load JS");
}
}
Expand Down
Loading
Loading